From 10147cfd77568b863543b1d257a2c538f1546960 Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Sat, 11 Jul 2026 21:11:23 +0000 Subject: [PATCH 01/12] try some things --- .../webview-no-service-worker/extension.js | 53 +++++ .../webview-no-service-worker/media/image.svg | 4 + .../webview-no-service-worker/media/style.css | 13 ++ .../webview-no-service-worker/package.json | 36 +++ implementation-plan.md | 49 +++++ src/vs/code/electron-main/app.ts | 25 +++ .../common/extensionsApiProposals.ts | 3 + .../webview/common/webviewManagerService.ts | 14 ++ .../electron-main/webviewMainService.ts | 13 +- .../electron-main/webviewProtocolProvider.ts | 116 +++++++++- .../api/browser/mainThreadWebviews.ts | 2 + .../workbench/api/common/extHost.api.impl.ts | 2 +- .../workbench/api/common/extHost.protocol.ts | 1 + src/vs/workbench/api/common/extHostWebview.ts | 23 +- .../api/test/browser/extHostWebview.test.ts | 6 +- .../contrib/webview/browser/overlayWebview.ts | 8 + .../contrib/webview/browser/webview.ts | 3 + .../contrib/webview/browser/webviewElement.ts | 46 +++- .../contrib/webview/common/webview.ts | 17 +- .../electron-browser/webviewElement.ts | 206 ++++++++++++++++++ ...scode.proposed.webviewNoServiceWorker.d.ts | 7 + 21 files changed, 631 insertions(+), 16 deletions(-) create mode 100644 extensions/webview-no-service-worker/extension.js create mode 100644 extensions/webview-no-service-worker/media/image.svg create mode 100644 extensions/webview-no-service-worker/media/style.css create mode 100644 extensions/webview-no-service-worker/package.json create mode 100644 implementation-plan.md create mode 100644 src/vscode-dts/vscode.proposed.webviewNoServiceWorker.d.ts diff --git a/extensions/webview-no-service-worker/extension.js b/extensions/webview-no-service-worker/extension.js new file mode 100644 index 00000000000000..be98633d32da1f --- /dev/null +++ b/extensions/webview-no-service-worker/extension.js @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +const vscode = require('vscode'); + +/** @param {vscode.ExtensionContext} context */ +function activate(context) { + context.subscriptions.push(vscode.commands.registerCommand('webviewNoServiceWorker.open', () => { + const panel = vscode.window.createWebviewPanel( + 'webviewNoServiceWorker.test', + 'Single-Iframe Webview', + vscode.ViewColumn.Active, + { enableScripts: true, localResourceRoots: [vscode.Uri.joinPath(context.extensionUri, 'media')] } + ); + const style = panel.webview.asWebviewUri(vscode.Uri.joinPath(context.extensionUri, 'media', 'style.css')); + const image = panel.webview.asWebviewUri(vscode.Uri.joinPath(context.extensionUri, 'media', 'image.svg')); + const nonce = 'vscodeSingleIframeTest'; + panel.webview.html = ` + + + + + +

Single-iframe webview

+ VS Code test image +

Waiting for VS Code API…

+ + + `; + panel.webview.onDidReceiveMessage(message => { + if (message.type === 'ready') { + void vscode.window.setStatusBarMessage('Single-iframe webview ready', 3000); + } + }); + })); +} + +exports.activate = activate; diff --git a/extensions/webview-no-service-worker/media/image.svg b/extensions/webview-no-service-worker/media/image.svg new file mode 100644 index 00000000000000..6b178f097c1403 --- /dev/null +++ b/extensions/webview-no-service-worker/media/image.svg @@ -0,0 +1,4 @@ + + + + diff --git a/extensions/webview-no-service-worker/media/style.css b/extensions/webview-no-service-worker/media/style.css new file mode 100644 index 00000000000000..643c4b1c5f2fed --- /dev/null +++ b/extensions/webview-no-service-worker/media/style.css @@ -0,0 +1,13 @@ +body { + font-family: var(--vscode-font-family); + color: var(--vscode-foreground); + background: var(--vscode-editor-background); + padding: 24px; +} + +button { + color: var(--vscode-button-foreground); + background: var(--vscode-button-background); + border: 0; + padding: 8px 12px; +} diff --git a/extensions/webview-no-service-worker/package.json b/extensions/webview-no-service-worker/package.json new file mode 100644 index 00000000000000..d2108f123dd216 --- /dev/null +++ b/extensions/webview-no-service-worker/package.json @@ -0,0 +1,36 @@ +{ + "name": "webview-no-service-worker", + "displayName": "Webview Without Service Worker (Experimental)", + "description": "Manual test extension for the experimental single-iframe Electron webview loader.", + "version": "0.0.1", + "publisher": "vscode", + "license": "MIT", + "private": true, + "engines": { + "vscode": "^1.105.0" + }, + "enabledApiProposals": [ + "webviewNoServiceWorker" + ], + "extensionKind": [ + "ui" + ], + "capabilities": { + "untrustedWorkspaces": { + "supported": true + } + }, + "main": "./extension.js", + "activationEvents": [ + "onCommand:webviewNoServiceWorker.open" + ], + "contributes": { + "commands": [ + { + "command": "webviewNoServiceWorker.open", + "title": "Open Single-Iframe Webview", + "category": "Developer" + } + ] + } +} diff --git a/implementation-plan.md b/implementation-plan.md new file mode 100644 index 00000000000000..9817bfd9338a71 --- /dev/null +++ b/implementation-plan.md @@ -0,0 +1,49 @@ +# Single-Iframe Electron Webviews Without Service Workers + +## Summary + +- Add an experimental Electron-only loader selected with `"enabledApiProposals": ["webviewNoServiceWorker"]`. +- Use exactly one iframe loading `vscode-webview:////index.html`. +- Use the normalized extension ID as the readable authority and the existing webview handle/iframe ID as the instance ID. These are routing identifiers, not secrets. +- Sandbox the iframe with `allow-scripts` and conditional forms/downloads/pointer-lock permissions, but never `allow-same-origin`. +- Keep the existing service-worker implementation unchanged for non-opted extensions and VS Code for the Web. + +## Identity and security boundaries + +- Register each `/` pair with its owning workbench window and iframe. +- Validate every document and resource request against that registration, its requesting frame, and the instance's current `localResourceRoots`. +- Do not authorize from CORS, `Origin`, or identifier secrecy. Opaque sandbox frames serialize their origin as `null`. +- Reject cross-extension and cross-instance document navigations and resource requests, including instances owned by the same extension. +- Revoke registrations and cancel outstanding work when an iframe or owning window is disposed. + +## Document and API loading + +- Enable the loader only when the proposal is granted and the UI client is Electron. +- In the workbench renderer, transform dynamic `webview.html`, inject default styles and a static hashed bootstrap, extract its CSP, and register the revision with Electron main before navigation. +- Navigate the single iframe directly to the registered document. Re-register and navigate on HTML changes, reject stale revisions, and queue messages while a new document handshakes. +- The bootstrap installs `acquireVsCodeApi`, owns a `MessageChannel`, preserves state and messaging semantics, and handles themes, focus, keyboard, drag/drop, links, and load events. +- Validate the handshake with `event.source`, instance ID, document revision, and a per-navigation nonce rather than `event.origin`. + +## Protocol and resources + +- In experimental mode, make `asWebviewUri` return resources below `vscode-webview:////_vscode/resource/...`; preserve source paths, queries, and fragments. Keep HTTP(S) and legacy behavior unchanged. +- Extend the existing Electron protocol handler to serve registered documents and authorized resources for `GET` and `HEAD` only. +- Keep file reads in the workbench renderer so remote and virtual providers work. Bridge request metadata, streaming chunks, ranges, conditional headers, and cancellation between Electron main and the owning `ElectronWebviewElement`. +- Preserve current status, MIME, ETag, range, media, and port-mapping behavior without Cache Storage. +- Decode resource URIs once, reject malformed/traversal inputs, enforce `localResourceRoots`, and use canonical real paths where the provider supports them. + +## CSP + +- Require one non-empty extension-authored CSP meta tag, remove it from the document, and return it as a response header. +- Add the exact bootstrap hash. When extension scripts are disabled, add an enforcement policy allowing only the bootstrap. +- Fail closed for missing or ambiguous CSP; do not silently fall back. +- Add `Referrer-Policy: no-referrer`, `X-Content-Type-Options: nosniff`, appropriate CORS/CORP headers, and `Cache-Control: no-store` for HTML. + +## Compatibility and tests + +- Leave legacy extensions, browser clients, internal webviews, and notebook renderers unchanged. +- Treat origin-bound storage and service workers as unsupported in the opaque-origin experimental mode; persistent state uses the VS Code API. +- Add a minimal built-in extension with one command, a panel, stylesheet, image, API message round trip, and persisted visible state. +- Add unit coverage for gating, URL parsing, CSP transformation, registrations and ownership, revisions, cancellation, root containment, symlink escape, ranges, CORS, and headers. +- Add Electron coverage for the single iframe, readable URL, opaque sandbox, API behavior, resources, remote/virtual providers, port mapping, cross-instance denial, absence of worker/cache activity, and legacy compatibility. +- Begin with a feasibility check for opaque custom-protocol documents, response-header CSP, parent messaging, modules, fonts, media, and fetch. Do not reintroduce the second iframe or `allow-same-origin` if a Chromium limitation is found. diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 3823d3ff535759..0dd98cbab792ea 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -94,6 +94,7 @@ import { NativeURLService } from '../../platform/url/common/urlService.js'; import { ElectronURLListener } from '../../platform/url/electron-main/electronUrlListener.js'; import { IWebviewManagerService } from '../../platform/webview/common/webviewManagerService.js'; import { WebviewMainService } from '../../platform/webview/electron-main/webviewMainService.js'; +import { WebviewProtocolProvider } from '../../platform/webview/electron-main/webviewProtocolProvider.js'; import { isFolderToOpen, isWorkspaceToOpen, IWindowOpenable } from '../../platform/window/common/window.js'; import { getAllWindowsExcludingOffscreen, IWindowsMainService, OpenContext } from '../../platform/windows/electron-main/windows.js'; import { ICodeWindow } from '../../platform/window/electron-main/window.js'; @@ -326,6 +327,30 @@ export class CodeApplication extends Disposable { }; const isAllowedWebviewRequest = (uri: URI, details: Electron.OnBeforeRequestListenerDetails): boolean => { + const directDocument = WebviewProtocolProvider.getWebviewDocument(uri); + if (directDocument) { + const frame = details.frame; + const owner = this.windowsMainService?.getWindowById(directDocument.windowId)?.win; + if (!frame || !owner) { + return false; + } + let belongsToOwner = false; + for (let current: WebFrameMain | null = frame; current; current = current.parent) { + if (current === owner.webContents.mainFrame) { + belongsToOwner = true; + break; + } + } + if (!belongsToOwner) { + return false; + } + const route = `${Schemas.vscodeWebview}://${directDocument.extensionId.toLowerCase()}/${encodeURIComponent(directDocument.webviewId)}/`; + const isInitialNavigation = frame === owner.webContents.mainFrame + || frame.url === '' + || frame.url === 'about:blank' + || frame.url.startsWith(`${Schemas.vscodeFileResource}://`); + return isInitialNavigation || frame.url.startsWith(route); + } if (uri.path !== '/index.html') { return true; // Only restrict top level page of webviews: index.html } diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index d5630b57899d39..089beab85ae80b 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -531,6 +531,9 @@ const _allApiProposals = { valueSelectionInQuickPick: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.valueSelectionInQuickPick.d.ts', }, + webviewNoServiceWorker: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.webviewNoServiceWorker.d.ts', + }, workspaceTrust: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.workspaceTrust.d.ts', } diff --git a/src/vs/platform/webview/common/webviewManagerService.ts b/src/vs/platform/webview/common/webviewManagerService.ts index ff195b9d2ad701..ea444620158c92 100644 --- a/src/vs/platform/webview/common/webviewManagerService.ts +++ b/src/vs/platform/webview/common/webviewManagerService.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../base/common/event.js'; +import { UriComponents } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; export const IWebviewManagerService = createDecorator('webviewManagerService'); @@ -34,9 +35,22 @@ export interface IWebviewManagerService { readonly onFoundInFrame: Event; + registerWebviewDocument(document: WebviewDocumentRegistration): Promise; + unregisterWebviewDocument(extensionId: string, webviewId: string): Promise; + setIgnoreMenuShortcuts(id: WebviewWebContentsId | WebviewWindowId, enabled: boolean): Promise; findInFrame(windowId: WebviewWindowId, frameName: string, text: string, options: FindInFrameOptions): Promise; stopFindInFrame(windowId: WebviewWindowId, frameName: string, options: { keepSelection?: boolean }): Promise; } + +export interface WebviewDocumentRegistration { + readonly extensionId: string; + readonly webviewId: string; + readonly windowId: number; + readonly revision: number; + readonly html: string; + readonly csp: string; + readonly roots: readonly UriComponents[]; +} diff --git a/src/vs/platform/webview/electron-main/webviewMainService.ts b/src/vs/platform/webview/electron-main/webviewMainService.ts index 9c583a0d95a6a5..ddef3b74ee2939 100644 --- a/src/vs/platform/webview/electron-main/webviewMainService.ts +++ b/src/vs/platform/webview/electron-main/webviewMainService.ts @@ -6,7 +6,7 @@ import { WebContents, webContents, WebFrameMain } from 'electron'; import { Emitter } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; -import { FindInFrameOptions, FoundInFrameResult, IWebviewManagerService, WebviewWebContentsId, WebviewWindowId } from '../common/webviewManagerService.js'; +import { FindInFrameOptions, FoundInFrameResult, IWebviewManagerService, WebviewDocumentRegistration, WebviewWebContentsId, WebviewWindowId } from '../common/webviewManagerService.js'; import { WebviewProtocolProvider } from './webviewProtocolProvider.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { IFileService } from '../../files/common/files.js'; @@ -17,13 +17,22 @@ export class WebviewMainService extends Disposable implements IWebviewManagerSer private readonly _onFoundInFrame = this._register(new Emitter()); public readonly onFoundInFrame = this._onFoundInFrame.event; + private readonly protocolProvider: WebviewProtocolProvider; constructor( @IFileService fileService: IFileService, @IWindowsMainService private readonly windowsMainService: IWindowsMainService, ) { super(); - this._register(new WebviewProtocolProvider(fileService)); + this.protocolProvider = this._register(new WebviewProtocolProvider(fileService)); + } + + public async registerWebviewDocument(document: WebviewDocumentRegistration): Promise { + this.protocolProvider.registerWebviewDocument(document); + } + + public async unregisterWebviewDocument(extensionId: string, webviewId: string): Promise { + this.protocolProvider.unregisterWebviewDocument(extensionId, webviewId); } public async setIgnoreMenuShortcuts(id: WebviewWebContentsId | WebviewWindowId, enabled: boolean): Promise { diff --git a/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts b/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts index 9f8a59c68aed44..fdd1db9dac509e 100644 --- a/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts +++ b/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts @@ -6,11 +6,14 @@ import { protocol } from 'electron'; import { IDisposable } from '../../../base/common/lifecycle.js'; import { AppResourcePath, COI, FileAccess, Schemas } from '../../../base/common/network.js'; -import { URI } from '../../../base/common/uri.js'; +import { URI, UriComponents } from '../../../base/common/uri.js'; import { IFileService } from '../../files/common/files.js'; +import { getWebviewContentMimeType } from '../common/mimeTypes.js'; +import { WebviewDocumentRegistration } from '../common/webviewManagerService.js'; export class WebviewProtocolProvider implements IDisposable { + private static readonly documents = new Map(); private static validWebviewFilePaths = new Map([ ['/index.html', { mime: 'text/html' }], @@ -30,9 +33,33 @@ export class WebviewProtocolProvider implements IDisposable { protocol.unhandle(Schemas.vscodeWebview); } + public registerWebviewDocument(document: WebviewDocumentRegistration): void { + WebviewProtocolProvider.documents.set(this.documentKey(document.extensionId, document.webviewId), document); + } + + public unregisterWebviewDocument(extensionId: string, webviewId: string): void { + WebviewProtocolProvider.documents.delete(this.documentKey(extensionId, webviewId)); + } + + public static getWebviewDocument(url: URI): WebviewDocumentRegistration | undefined { + const match = /^\/([^/]+)\/(?:index\.html|_vscode\/resource\/)/.exec(url.path); + if (!match) { + return undefined; + } + return this.documents.get(`${url.authority.toLowerCase()}\0${decodeURIComponent(match[1])}`); + } + + private documentKey(extensionId: string, webviewId: string): string { + return `${extensionId.toLowerCase()}\0${webviewId}`; + } + private async handleWebviewRequest(request: GlobalRequest): Promise { try { const uri = URI.parse(request.url); + const directResponse = await this.handleDirectWebviewRequest(request, uri); + if (directResponse) { + return directResponse; + } const entry = WebviewProtocolProvider.validWebviewFilePaths.get(uri.path); if (entry) { const relativeResourcePath: AppResourcePath = `vs/workbench/contrib/webview/browser/pre${uri.path}`; @@ -54,4 +81,91 @@ export class WebviewProtocolProvider implements IDisposable { } return new Response(null, { status: 500 }); } + + private async handleDirectWebviewRequest(request: GlobalRequest, uri: URI): Promise { + const match = /^\/([^/]+)\/(index\.html|_vscode\/resource\/(.+))$/.exec(uri.path); + if (!match) { + return undefined; + } + if (request.method !== 'GET' && request.method !== 'HEAD') { + return new Response(null, { status: 405 }); + } + + const webviewId = decodeURIComponent(match[1]); + const document = WebviewProtocolProvider.documents.get(this.documentKey(uri.authority, webviewId)); + if (!document) { + return new Response(null, { status: 404 }); + } + + if (match[2] === 'index.html') { + const revision = Number(new URL(request.url).searchParams.get('revision')); + if (revision !== document.revision) { + return new Response(null, { status: 410 }); + } + return new Response(request.method === 'HEAD' ? null : document.html, { + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Content-Security-Policy': document.csp, + 'Cache-Control': 'no-store', + 'Referrer-Policy': 'no-referrer', + 'X-Content-Type-Options': 'nosniff', + 'Cross-Origin-Resource-Policy': 'cross-origin', + } + }); + } + + const resource = this.decodeResourceUri(match[3]); + if (!resource || !this.isAllowedResource(resource, document.roots)) { + return new Response(null, { status: 403 }); + } + try { + const content = await this._fileService.readFile(resource); + return new Response(request.method === 'HEAD' ? null : content.value.buffer as ArrayBufferView, { + headers: { + 'Content-Type': getWebviewContentMimeType(resource), + 'Access-Control-Allow-Origin': '*', + 'Cross-Origin-Resource-Policy': 'cross-origin', + 'X-Content-Type-Options': 'nosniff', + } + }); + } catch { + return new Response(null, { status: 404 }); + } + } + + private decodeResourceUri(value: string): URI | undefined { + try { + const slash = value.indexOf('/'); + const encodedOrigin = slash < 0 ? value : value.slice(0, slash); + const plus = encodedOrigin.indexOf('+'); + if (plus <= 0) { + return undefined; + } + return URI.from({ + scheme: encodedOrigin.slice(0, plus), + authority: this.decodeAuthority(encodedOrigin.slice(plus + 1)), + path: slash < 0 ? '/' : value.slice(slash), + }); + } catch { + return undefined; + } + } + + private decodeAuthority(authority: string): string { + return authority.replace(/-([0-9a-f]{4})/g, (_, code) => String.fromCharCode(parseInt(code, 16))); + } + + private isAllowedResource(resource: URI, roots: readonly UriComponents[]): boolean { + for (const rawRoot of roots) { + const root = URI.revive(rawRoot); + const normalizedRoot = root.path.endsWith('/') ? root.path : `${root.path}/`; + if (root.scheme === resource.scheme + && root.authority === resource.authority + && resource.path.startsWith(normalizedRoot) + && !resource.path.split('/').includes('..')) { + return true; + } + } + return false; + } } diff --git a/src/vs/workbench/api/browser/mainThreadWebviews.ts b/src/vs/workbench/api/browser/mainThreadWebviews.ts index 0d5997132362aa..ae6c1c5451435b 100644 --- a/src/vs/workbench/api/browser/mainThreadWebviews.ts +++ b/src/vs/workbench/api/browser/mainThreadWebviews.ts @@ -48,6 +48,7 @@ export class MainThreadWebviews extends Disposable implements extHostProtocol.Ma throw new Error('Webview already registered'); } + webview.resourceId = handle; this._webviews.set(handle, webview); this.hookupWebviewEventDelegate(handle, webview, options); } @@ -145,6 +146,7 @@ export function reviveWebviewExtension(extensionData: extHostProtocol.WebviewExt return { id: extensionData.id, location: URI.revive(extensionData.location), + useSingleIframe: extensionData.useSingleIframe, }; } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index bf42962ec0b01f..0aedd86a0cc2a3 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -236,7 +236,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostLabelService = rpcProtocol.set(ExtHostContext.ExtHostLabelService, new ExtHostLabelService(rpcProtocol)); const extHostTheming = rpcProtocol.set(ExtHostContext.ExtHostTheming, new ExtHostTheming(rpcProtocol)); const extHostTimeline = rpcProtocol.set(ExtHostContext.ExtHostTimeline, new ExtHostTimeline(rpcProtocol, extHostCommands)); - const extHostWebviews = rpcProtocol.set(ExtHostContext.ExtHostWebviews, new ExtHostWebviews(rpcProtocol, initData.remote, extHostWorkspace, extHostLogService, extHostApiDeprecation)); + const extHostWebviews = rpcProtocol.set(ExtHostContext.ExtHostWebviews, new ExtHostWebviews(rpcProtocol, initData.remote, initData.uiKind, extHostWorkspace, extHostLogService, extHostApiDeprecation)); const extHostWebviewPanels = rpcProtocol.set(ExtHostContext.ExtHostWebviewPanels, new ExtHostWebviewPanels(rpcProtocol, extHostWebviews, extHostWorkspace)); const extHostCustomEditors = rpcProtocol.set(ExtHostContext.ExtHostCustomEditors, new ExtHostCustomEditors(rpcProtocol, extHostDocuments, extensionStoragePaths, extHostWebviews, extHostWebviewPanels)); const extHostWebviewViews = rpcProtocol.set(ExtHostContext.ExtHostWebviewViews, new ExtHostWebviewViews(rpcProtocol, extHostWebviews)); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index a4b2b6372c252e..1a100d814787a1 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1018,6 +1018,7 @@ export interface WebviewPanelShowOptions { export interface WebviewExtensionDescription { readonly id: ExtensionIdentifier; readonly location: UriComponents; + readonly useSingleIframe?: boolean; } export enum WebviewEditorCapabilities { diff --git a/src/vs/workbench/api/common/extHostWebview.ts b/src/vs/workbench/api/common/extHostWebview.ts index 574088e5aba80d..5734106fc2115a 100644 --- a/src/vs/workbench/api/common/extHostWebview.ts +++ b/src/vs/workbench/api/common/extHostWebview.ts @@ -17,6 +17,8 @@ import { deserializeWebviewMessage, serializeWebviewMessage } from './extHostWeb import { IExtHostWorkspace } from './extHostWorkspace.js'; import { WebviewRemoteInfo, asWebviewUri, webviewGenericCspSource } from '../../contrib/webview/common/webview.js'; import { SerializableObjectWithBuffers } from '../../services/extensions/common/proxyIdentifier.js'; +import { UIKind } from '../../services/extensions/common/extensionHostProtocol.js'; +import { isProposedApiEnabled } from '../../services/extensions/common/extensions.js'; import type * as vscode from 'vscode'; import * as extHostProtocol from './extHost.protocol.js'; @@ -29,6 +31,7 @@ export class ExtHostWebview implements vscode.Webview { readonly #remoteInfo: WebviewRemoteInfo; readonly #workspace: IExtHostWorkspace | undefined; readonly #extension: IExtensionDescription; + readonly #useSingleIframe: boolean; #html: string = ''; #options: vscode.WebviewOptions; @@ -45,6 +48,7 @@ export class ExtHostWebview implements vscode.Webview { remoteInfo: WebviewRemoteInfo, workspace: IExtHostWorkspace | undefined, extension: IExtensionDescription, + useSingleIframe: boolean, deprecationService: IExtHostApiDeprecationService, ) { this.#handle = handle; @@ -53,6 +57,7 @@ export class ExtHostWebview implements vscode.Webview { this.#remoteInfo = remoteInfo; this.#workspace = workspace; this.#extension = extension; + this.#useSingleIframe = useSingleIframe; this.#serializeBuffersForPostMessage = shouldSerializeBuffersForPostMessage(extension); this.#shouldRewriteOldResourceUris = shouldTryRewritingOldResourceUris(extension); this.#deprecationService = deprecationService; @@ -75,10 +80,16 @@ export class ExtHostWebview implements vscode.Webview { public asWebviewUri(resource: vscode.Uri): vscode.Uri { this.#hasCalledAsWebviewUri = true; - return asWebviewUri(resource, this.#remoteInfo); + return asWebviewUri(resource, this.#remoteInfo, this.#useSingleIframe ? { + extensionId: this.#extension.identifier.value, + webviewId: this.#handle, + } : undefined); } public get cspSource(): string { + if (this.#useSingleIframe) { + return `${Schemas.vscodeWebview}://${this.#extension.identifier.value.toLowerCase()}`; + } const extensionLocation = this.#extension.extensionLocation; if (extensionLocation.scheme === Schemas.https || extensionLocation.scheme === Schemas.http) { // The extension is being served up from a CDN. @@ -198,6 +209,7 @@ export class ExtHostWebviews extends Disposable implements extHostProtocol.ExtHo constructor( mainContext: extHostProtocol.IMainContext, private readonly remoteInfo: WebviewRemoteInfo, + private readonly uiKind: UIKind, private readonly workspace: IExtHostWorkspace | undefined, private readonly _logService: ILogService, private readonly _deprecationService: IExtHostApiDeprecationService, @@ -235,7 +247,8 @@ export class ExtHostWebviews extends Disposable implements extHostProtocol.ExtHo } public createNewWebview(handle: string, options: extHostProtocol.IWebviewContentOptions, extension: IExtensionDescription): ExtHostWebview { - const webview = new ExtHostWebview(handle, this._webviewProxy, reviveOptions(options), this.remoteInfo, this.workspace, extension, this._deprecationService); + const useSingleIframe = this.uiKind === UIKind.Desktop && isProposedApiEnabled(extension, 'webviewNoServiceWorker'); + const webview = new ExtHostWebview(handle, this._webviewProxy, reviveOptions(options), this.remoteInfo, this.workspace, extension, useSingleIframe, this._deprecationService); this._webviews.set(handle, webview); const sub = webview._onDidDispose(() => { @@ -276,7 +289,11 @@ export class ExtHostWebviews extends Disposable implements extHostProtocol.ExtHo } export function toExtensionData(extension: IExtensionDescription): extHostProtocol.WebviewExtensionDescription { - return { id: extension.identifier, location: extension.extensionLocation }; + return { + id: extension.identifier, + location: extension.extensionLocation, + useSingleIframe: isProposedApiEnabled(extension, 'webviewNoServiceWorker'), + }; } export function serializeWebviewOptions( diff --git a/src/vs/workbench/api/test/browser/extHostWebview.test.ts b/src/vs/workbench/api/test/browser/extHostWebview.test.ts index 3865db39383c78..6ae2519bd18b61 100644 --- a/src/vs/workbench/api/test/browser/extHostWebview.test.ts +++ b/src/vs/workbench/api/test/browser/extHostWebview.test.ts @@ -16,6 +16,7 @@ import { NullApiDeprecationService } from '../../common/extHostApiDeprecationSer import { IExtHostRpcService } from '../../common/extHostRpcService.js'; import { IWebviewContentOptions } from '../../common/extHost.protocol.js'; import { ExtHostWebviews } from '../../common/extHostWebview.js'; +import { UIKind } from '../../../services/extensions/common/extensionHostProtocol.js'; import { ExtHostWebviewPanels } from '../../common/extHostWebviewPanels.js'; import { IExtHostWorkspace } from '../../common/extHostWorkspace.js'; import { SingleProxyRPCProtocol } from '../common/testRPCProtocol.js'; @@ -45,7 +46,7 @@ suite('ExtHostWebview', () => { const extHostWebviews = disposables.add(new ExtHostWebviews(rpcProtocol!, { authority: remoteAuthority, isRemote: !!remoteAuthority, - }, undefined, new NullLogService(), NullApiDeprecationService)); + }, UIKind.Desktop, undefined, new NullLogService(), NullApiDeprecationService)); const extHostWebviewPanels = disposables.add(new ExtHostWebviewPanels(rpcProtocol!, extHostWebviews, undefined)); @@ -61,7 +62,7 @@ suite('ExtHostWebview', () => { test('Cannot register multiple serializers for the same view type', async () => { const viewType = 'view.type'; - const extHostWebviews = disposables.add(new ExtHostWebviews(rpcProtocol!, { authority: undefined, isRemote: false }, undefined, new NullLogService(), NullApiDeprecationService)); + const extHostWebviews = disposables.add(new ExtHostWebviews(rpcProtocol!, { authority: undefined, isRemote: false }, UIKind.Desktop, undefined, new NullLogService(), NullApiDeprecationService)); const extHostWebviewPanels = disposables.add(new ExtHostWebviewPanels(rpcProtocol!, extHostWebviews, undefined)); @@ -221,6 +222,7 @@ suite('ExtHostWebview', () => { const extHostWebviews = disposables.add(new ExtHostWebviews( captureRpc, { authority: undefined, isRemote: false }, + UIKind.Desktop, workspace, new NullLogService(), NullApiDeprecationService)); diff --git a/src/vs/workbench/contrib/webview/browser/overlayWebview.ts b/src/vs/workbench/contrib/webview/browser/overlayWebview.ts index b45c2e4fe08351..2c96904032531b 100644 --- a/src/vs/workbench/contrib/webview/browser/overlayWebview.ts +++ b/src/vs/workbench/contrib/webview/browser/overlayWebview.ts @@ -36,6 +36,7 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { private _title: string | undefined; private _initialScrollProgress: number = 0; private _state: string | undefined = undefined; + private _resourceId: string | undefined; private _extension: WebviewExtensionDescription | undefined; private _contentOptions: WebviewContentOptions; @@ -213,6 +214,7 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { extension: this.extension, }); this._webview.value = webview; + webview.resourceId = this._resourceId; webview.state = this._state; if (this._scopedContextKeyService.value) { @@ -400,4 +402,10 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { setContextKeyService(contextKeyService: IContextKeyService) { this._webview.value?.setContextKeyService(contextKeyService); } + + public get resourceId(): string | undefined { return this._resourceId; } + public set resourceId(value: string | undefined) { + this._resourceId = value; + this._withWebview(webview => webview.resourceId = value); + } } diff --git a/src/vs/workbench/contrib/webview/browser/webview.ts b/src/vs/workbench/contrib/webview/browser/webview.ts index f950b7b5a64e6b..27c70871bc32a5 100644 --- a/src/vs/workbench/contrib/webview/browser/webview.ts +++ b/src/vs/workbench/contrib/webview/browser/webview.ts @@ -183,6 +183,7 @@ function areEnableCommandUrisEqual(a: WebviewContentOptions, b: WebviewContentOp export interface WebviewExtensionDescription { readonly location?: URI; readonly id: ExtensionIdentifier; + readonly useSingleIframe?: boolean; } export interface WebviewMessageReceivedEvent { @@ -191,6 +192,8 @@ export interface WebviewMessageReceivedEvent { } export interface IWebview extends IDisposable { + /** Internal resource route identifier assigned by the extension host bridge. */ + resourceId?: string; /** * The original view type of the webview. diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index 10538d4cc95c6f..5fd4345e073dc6 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -77,6 +77,9 @@ const webviewIdContext = 'webviewId'; export class WebviewElement extends Disposable implements IWebviewElement, WebviewFindDelegate { protected readonly id = generateUuid(); + private _resourceId: string | undefined; + public get resourceId(): string | undefined { return this._resourceId; } + public set resourceId(value: string | undefined) { this._resourceId = value; this.onWebviewRouteChanged(); } /** * The provided identifier of this webview. @@ -89,6 +92,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi public readonly origin: string; private _windowId: number | undefined = undefined; + protected get windowId(): number | undefined { return this._windowId; } private get window() { return typeof this._windowId === 'number' ? getWindowById(this._windowId)?.window : undefined; } private _encodedWebviewOriginPromise?: Promise; @@ -135,6 +139,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi private _state: WebviewState.State = new WebviewState.Initializing([]); private _content: WebviewContent; + protected get content(): WebviewContent { return this._content; } private readonly _portMappingManager: WebviewPortMappingManager; @@ -161,7 +166,11 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi private _disposed = false; - public extension: WebviewExtensionDescription | undefined; + private _extension: WebviewExtensionDescription | undefined; + public get extension(): WebviewExtensionDescription | undefined { return this._extension; } + public set extension(value: WebviewExtensionDescription | undefined) { this._extension = value; this.onWebviewRouteChanged(); } + protected get useSingleIframe(): boolean { return this.platform === 'electron' && this.extension?.useSingleIframe === true; } + protected onWebviewRouteChanged(): void { } private readonly _options: WebviewOptions; constructor( @@ -412,13 +421,23 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi } } - private _createElement(options: WebviewOptions, _contentOptions: WebviewContentOptions) { + private _createElement(options: WebviewOptions, contentOptions: WebviewContentOptions) { // Do not start loading the webview yet. // Wait the end of the ctor when all listeners have been hooked up. const element = document.createElement('iframe'); element.name = this.id; element.className = `webview ${options.customClasses || ''}`; - element.sandbox.add('allow-scripts', 'allow-same-origin', 'allow-forms', 'allow-pointer-lock', 'allow-downloads'); + if (this.useSingleIframe) { + element.sandbox.add('allow-scripts', 'allow-pointer-lock'); + if (contentOptions.allowForms ?? contentOptions.allowScripts) { + element.sandbox.add('allow-forms'); + } + if (contentOptions.allowScripts) { + element.sandbox.add('allow-downloads'); + } + } else { + element.sandbox.add('allow-scripts', 'allow-same-origin', 'allow-forms', 'allow-pointer-lock', 'allow-downloads'); + } const allowRules = ['cross-origin-isolated', 'autoplay', 'local-network-access']; if (!isFirefox) { @@ -437,7 +456,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi return element; } - private _initElement(encodedWebviewOrigin: string, extension: WebviewExtensionDescription | undefined, options: WebviewOptions, targetWindow: CodeWindow) { + protected _initElement(encodedWebviewOrigin: string, extension: WebviewExtensionDescription | undefined, options: WebviewOptions, targetWindow: CodeWindow) { // The extensionId and purpose in the URL are used for filtering in js-debug: const params: { [key: string]: string } = { id: this.id, @@ -507,13 +526,16 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi element.appendChild(this.element); } - private _registerMessageHandler(targetWindow: CodeWindow) { + protected _registerMessageHandler(targetWindow: CodeWindow) { const subscription = this._register(addDisposableListener(targetWindow, 'message', (e: MessageEvent) => { if (!this._encodedWebviewOrigin || e?.data?.target !== this.id) { return; } - if (e.origin !== this._webviewContentOrigin(this._encodedWebviewOrigin)) { + const validOrigin = this.useSingleIframe + ? e.origin === 'null' && e.source === this.element?.contentWindow + : e.origin === this._webviewContentOrigin(this._encodedWebviewOrigin); + if (!validOrigin) { console.log(`Skipped renderer receiving message due to mismatched origins: ${e.origin} ${this._webviewContentOrigin}`); return; } @@ -548,6 +570,13 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi })); } + protected prepareForDirectNavigation(targetWindow: CodeWindow): void { + this._messagePort = undefined; + const pending = this._state.type === WebviewState.Type.Initializing ? this._state.pendingMessages : []; + this._state = new WebviewState.Initializing(pending); + this._registerMessageHandler(targetWindow); + } + private perfMark(name: string) { performance.mark(`webview/webviewElement/${name}`, { detail: { @@ -660,10 +689,12 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi ...this._content, options: { ...this._content.options, localResourceRoots: resources } }; + this.onContentDidChange(); } public set state(state: string | undefined) { this._content = { ...this._content, state }; + this.onContentDidChange(); } public set initialScrollProgress(value: number) { @@ -674,6 +705,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi this._logService.debug(`Webview(${this.id}): will update content`); this._content = newContent; + this.onContentDidChange(); const allowScripts = !!this._content.options.allowScripts; this.perfMark('set-content'); @@ -691,6 +723,8 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi }); } + protected onContentDidChange(): void { } + protected style(): void { let { styles, activeTheme, themeLabel, themeId } = this.webviewThemeDataProvider.getWebviewThemeData(); if (this._options.transformCssVariables) { diff --git a/src/vs/workbench/contrib/webview/common/webview.ts b/src/vs/workbench/contrib/webview/common/webview.ts index 95c65048fcd89f..f934a84e4ba9e9 100644 --- a/src/vs/workbench/contrib/webview/common/webview.ts +++ b/src/vs/workbench/contrib/webview/common/webview.ts @@ -24,6 +24,11 @@ export const webviewRootResourceAuthority = `vscode-resource.${webviewResourceBa export const webviewGenericCspSource = `'self' https://*.${webviewResourceBaseHost}`; +export interface ElectronWebviewResourceRoute { + readonly extensionId: string; + readonly webviewId: string; +} + /** * Construct a uri that can load resources inside a webview * @@ -37,7 +42,7 @@ export const webviewGenericCspSource = `'self' https://*.${webviewResourceBaseHo * @param resource Uri of the resource to load. * @param remoteInfo Optional information about the remote that specifies where `resource` should be resolved from. */ -export function asWebviewUri(resource: URI, remoteInfo?: WebviewRemoteInfo): URI { +export function asWebviewUri(resource: URI, remoteInfo?: WebviewRemoteInfo, electronRoute?: ElectronWebviewResourceRoute): URI { if (resource.scheme === Schemas.http || resource.scheme === Schemas.https) { return resource; } @@ -50,6 +55,16 @@ export function asWebviewUri(resource: URI, remoteInfo?: WebviewRemoteInfo): URI }); } + if (electronRoute) { + return URI.from({ + scheme: Schemas.vscodeWebview, + authority: electronRoute.extensionId.toLowerCase(), + path: `/${encodeURIComponent(electronRoute.webviewId)}/_vscode/resource/${resource.scheme}+${encodeAuthority(resource.authority)}${resource.path}`, + fragment: resource.fragment, + query: resource.query, + }); + } + return URI.from({ scheme: Schemas.https, authority: `${resource.scheme}+${encodeAuthority(resource.authority)}.${webviewRootResourceAuthority}`, diff --git a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts index ceea64ff9e0b60..4ba8f5209c3224 100644 --- a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { Delayer } from '../../../../base/common/async.js'; +import { CodeWindow } from '../../../../base/browser/window.js'; +import { createTrustedTypesPolicy } from '../../../../base/browser/trustedTypes.js'; import { Schemas } from '../../../../base/common/network.js'; import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; @@ -23,6 +25,66 @@ import { WebviewInitInfo } from '../browser/webview.js'; import { WebviewElement } from '../browser/webviewElement.js'; import { WindowIgnoreMenuShortcutsManager } from './windowIgnoreMenuShortcutsManager.js'; +const singleIframeBootstrap = String.raw`(() => { + const params = new URL(location.href).searchParams; + const target = params.get('target'); + const parentOrigin = params.get('parentOrigin'); + const channel = new MessageChannel(); + let acquired = false; + const stateElement = document.querySelector('meta[name="vscode-webview-state"]'); + let state = stateElement?.content ? JSON.parse(decodeURIComponent(stateElement.content)) : undefined; + stateElement?.remove(); + const pending = []; + const post = (channelName, data, transfer = []) => channel.port1.postMessage({ channel: channelName, data }, transfer); + + globalThis.acquireVsCodeApi = () => { + if (acquired) { throw new Error('An instance of the VS Code API has already been acquired'); } + acquired = true; + return Object.freeze({ + postMessage(message, transfer) { post('onmessage', { message, transfer }, transfer); }, + setState(newState) { state = newState; post('do-update-state', JSON.stringify(newState)); return newState; }, + getState() { return state; } + }); + }; + + const applyStyles = data => { + for (const [key, value] of Object.entries(data.styles || {})) { + document.documentElement.style.setProperty('--' + key, String(value)); + } + document.body?.classList.remove('vscode-light', 'vscode-dark', 'vscode-high-contrast', 'vscode-high-contrast-light'); + if (data.activeTheme) { document.body?.classList.add(data.activeTheme); } + document.body?.setAttribute('data-vscode-theme-kind', data.activeTheme || ''); + document.body?.setAttribute('data-vscode-theme-name', data.themeLabel || ''); + document.body?.setAttribute('data-vscode-theme-id', data.themeId || ''); + }; + + channel.port1.onmessage = event => { + const { channel: name, args } = event.data; + switch (name) { + case 'content': state = args.state; document.title = args.title || ''; break; + case 'styles': applyStyles(args); break; + case 'message': window.dispatchEvent(new MessageEvent('message', { data: args.message })); break; + case 'focus': window.focus(); break; + case 'execCommand': document.execCommand(args); break; + case 'initial-scroll-position': window.scrollTo(0, document.body.scrollHeight * args); break; + case 'set-title': document.title = args; break; + } + }; + + window.addEventListener('focus', () => post('did-focus')); + window.addEventListener('blur', () => post('did-blur')); + window.addEventListener('scroll', () => post('did-scroll', { scrollYPercentage: document.body.scrollHeight ? scrollY / document.body.scrollHeight : 0 }), { passive: true }); + window.addEventListener('wheel', event => post('did-scroll-wheel', { deltaMode: event.deltaMode, deltaX: event.deltaX, deltaY: event.deltaY, deltaZ: event.deltaZ }), { passive: true }); + document.addEventListener('click', event => { + const anchor = event.target instanceof Element ? event.target.closest('a[href]') : null; + if (anchor) { event.preventDefault(); post('did-click-link', { uri: anchor.href }); } + }); + + parent.postMessage({ target, channel: 'webview-ready' }, parentOrigin, [channel.port2]); +})();`; + +const singleIframeHtmlPolicy = createTrustedTypesPolicy('singleIframeWebview', { createHTML: value => value }); + /** * Webview backed by an iframe but that uses Electron APIs to power the webview. */ @@ -35,6 +97,10 @@ export class ElectronWebviewElement extends WebviewElement { private readonly _webviewMainService: IWebviewManagerService; private readonly _iframeDelayer = this._register(new Delayer(200)); + private _directTargetWindow: CodeWindow | undefined; + private _directRevision = 0; + private _directContentKey: string | undefined; + private _directUpdate: Promise = Promise.resolve(); protected override get platform() { return 'electron'; } @@ -79,9 +145,149 @@ export class ElectronWebviewElement extends WebviewElement { // Make sure keyboard handler knows it closed (#71800) this._webviewKeyboardHandler.didBlur(); + if (this.extension?.useSingleIframe && this.resourceId) { + void this._webviewMainService.unregisterWebviewDocument(this.extension.id.value, this.resourceId); + } super.dispose(); } + public override reload(): void { + if (!this.useSingleIframe) { + super.reload(); + return; + } + this._directContentKey = undefined; + void this.updateDirectDocument(true); + } + + protected override _initElement(encodedWebviewOrigin: string, extension: WebviewInitInfo['extension'], options: WebviewInitInfo['options'], targetWindow: CodeWindow): void { + if (!this.useSingleIframe) { + super._initElement(encodedWebviewOrigin, extension, options, targetWindow); + return; + } + this._directTargetWindow = targetWindow; + void this.updateDirectDocument(false); + } + + protected override onWebviewRouteChanged(): void { + if (this.useSingleIframe && this._directTargetWindow) { + void this.updateDirectDocument(true); + } + } + + protected override onContentDidChange(): void { + if (this.useSingleIframe && this._directTargetWindow) { + void this.updateDirectDocument(true); + } + } + + private async updateDirectDocument(prepareForNavigation: boolean): Promise { + const extensionId = this.extension?.id.value.toLowerCase(); + const webviewId = this.resourceId; + const targetWindow = this._directTargetWindow; + if (!extensionId || !webviewId || !targetWindow || typeof this.windowId !== 'number' || !this.element) { + return; + } + + const contentKey = JSON.stringify({ + html: this.content.html, + allowScripts: this.content.options.allowScripts, + allowForms: this.content.options.allowForms, + roots: this.content.options.localResourceRoots?.map(root => root.toString()), + }); + if (contentKey === this._directContentKey) { + return; + } + this._directContentKey = contentKey; + + this._directUpdate = this._directUpdate.then(async () => { + const revision = ++this._directRevision; + const transformed = await this.transformDirectHtml(this.content.html, !!this.content.options.allowScripts); + if (revision !== this._directRevision) { + return; + } + await this._webviewMainService.registerWebviewDocument({ + extensionId, + webviewId, + windowId: this.windowId!, + revision, + html: transformed.html, + csp: transformed.csp, + roots: this.content.options.localResourceRoots || [], + }); + if (revision !== this._directRevision || !this.element) { + return; + } + + this.element.sandbox.remove('allow-same-origin', 'allow-forms', 'allow-downloads'); + this.element.sandbox.add('allow-scripts', 'allow-pointer-lock'); + if (this.content.options.allowForms ?? this.content.options.allowScripts) { + this.element.sandbox.add('allow-forms'); + } + if (this.content.options.allowScripts) { + this.element.sandbox.add('allow-downloads'); + } + if (prepareForNavigation) { + this.prepareForDirectNavigation(targetWindow); + this.style(); + } + const query = new URLSearchParams({ + revision: String(revision), + target: this.id, + parentOrigin: targetWindow.origin, + }); + this.element.src = `${Schemas.vscodeWebview}://${extensionId}/${encodeURIComponent(webviewId)}/index.html?${query}`; + }); + await this._directUpdate; + } + + private async transformDirectHtml(html: string, allowScripts: boolean): Promise<{ html: string; csp: string }> { + const source = html || ''; + const trustedSource = singleIframeHtmlPolicy?.createHTML(source) ?? source; + const document = new DOMParser().parseFromString(trustedSource as string, 'text/html'); + const policies = document.querySelectorAll('meta[http-equiv="Content-Security-Policy" i]'); + if (policies.length !== 1 || !policies[0].getAttribute('content')?.trim()) { + return { + html: 'Webview blocked: the experimental loader requires exactly one Content-Security-Policy meta tag.', + csp: "default-src 'none'; style-src 'unsafe-inline'", + }; + } + let csp = policies[0].getAttribute('content')!.trim(); + policies[0].remove(); + const hash = await this.bootstrapHash(); + csp = this.addBootstrapHash(csp, hash); + if (!allowScripts) { + csp += `, script-src ${hash}; script-src-attr 'none'`; + } + const script = document.createElement('script'); + script.textContent = singleIframeBootstrap; + document.head.prepend(script); + const state = document.createElement('meta'); + state.name = 'vscode-webview-state'; + state.content = this.content.state ? encodeURIComponent(this.content.state) : ''; + document.head.prepend(state); + document.title = this.content.title || ''; + return { html: `\n${document.documentElement.outerHTML}`, csp }; + } + + private async bootstrapHash(): Promise { + const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(singleIframeBootstrap))); + let binary = ''; + for (const value of digest) { binary += String.fromCharCode(value); } + return `'sha256-${btoa(binary)}'`; + } + + private addBootstrapHash(csp: string, hash: string): string { + const directives = csp.split(';').map(value => value.trim()).filter(Boolean); + const scriptIndex = directives.findIndex(value => value.toLowerCase().startsWith('script-src ')); + if (scriptIndex >= 0) { + directives[scriptIndex] += ` ${hash}`; + } else { + directives.push(`script-src ${hash}`); + } + return directives.join('; '); + } + protected override webviewContentEndpoint(iframeId: string): string { return `${Schemas.vscodeWebview}://${iframeId}`; } diff --git a/src/vscode-dts/vscode.proposed.webviewNoServiceWorker.d.ts b/src/vscode-dts/vscode.proposed.webviewNoServiceWorker.d.ts new file mode 100644 index 00000000000000..175226c1e30ea8 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.webviewNoServiceWorker.d.ts @@ -0,0 +1,7 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// This proposal is selected through package.json#enabledApiProposals. It does not add API members. +declare module 'vscode' { } From f36b666803d7ad5edba3c7392736758946f7dad5 Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Sat, 11 Jul 2026 21:33:29 +0000 Subject: [PATCH 02/12] try some things --- .../workbench/workbench-dev.html | 1 + .../electron-browser/workbench/workbench.html | 1 + .../webview/common/webviewManagerService.ts | 26 ++++ .../electron-main/webviewMainService.ts | 24 ++- .../electron-main/webviewProtocolProvider.ts | 130 ++++++++++++---- .../webview/browser/resourceLoading.ts | 36 +++++ .../contrib/webview/browser/webviewElement.ts | 13 ++ .../contrib/webview/common/webview.ts | 15 +- .../electron-browser/webviewElement.ts | 146 ++++++++++++++++-- .../test/browser/resourceLoading.test.ts | 28 ++++ 10 files changed, 375 insertions(+), 45 deletions(-) diff --git a/src/vs/code/electron-browser/workbench/workbench-dev.html b/src/vs/code/electron-browser/workbench/workbench-dev.html index 9e2d94074bc3d3..cd21642d6c78a9 100644 --- a/src/vs/code/electron-browser/workbench/workbench-dev.html +++ b/src/vs/code/electron-browser/workbench/workbench-dev.html @@ -70,6 +70,7 @@ notebookChatEditController richScreenReaderContent chatDebugTokenizer + singleIframeWebview ; "/> diff --git a/src/vs/code/electron-browser/workbench/workbench.html b/src/vs/code/electron-browser/workbench/workbench.html index 47c5748b8a3395..7d4706ec2ffcb9 100644 --- a/src/vs/code/electron-browser/workbench/workbench.html +++ b/src/vs/code/electron-browser/workbench/workbench.html @@ -66,6 +66,7 @@ notebookChatEditController richScreenReaderContent chatDebugTokenizer + singleIframeWebview ; "/> diff --git a/src/vs/platform/webview/common/webviewManagerService.ts b/src/vs/platform/webview/common/webviewManagerService.ts index ea444620158c92..6907376d54df3d 100644 --- a/src/vs/platform/webview/common/webviewManagerService.ts +++ b/src/vs/platform/webview/common/webviewManagerService.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../base/common/event.js'; +import { VSBuffer } from '../../../base/common/buffer.js'; import { UriComponents } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; @@ -34,9 +35,14 @@ export interface IWebviewManagerService { _serviceBrand: unknown; readonly onFoundInFrame: Event; + readonly onDidRequestWebviewResource: Event; + readonly onDidCancelWebviewResource: Event; registerWebviewDocument(document: WebviewDocumentRegistration): Promise; unregisterWebviewDocument(extensionId: string, webviewId: string): Promise; + startWebviewResourceResponse(response: WebviewResourceResponse): Promise; + streamWebviewResourceResponse(requestId: number, data: VSBuffer): Promise; + endWebviewResourceResponse(requestId: number, error?: boolean): Promise; setIgnoreMenuShortcuts(id: WebviewWebContentsId | WebviewWindowId, enabled: boolean): Promise; @@ -54,3 +60,23 @@ export interface WebviewDocumentRegistration { readonly csp: string; readonly roots: readonly UriComponents[]; } + +export interface WebviewResourceRequest { + readonly requestId: number; + readonly extensionId: string; + readonly webviewId: string; + readonly method: 'GET' | 'HEAD'; + readonly uri: UriComponents; + readonly ifNoneMatch: string | undefined; + readonly range: { readonly start: number; readonly end?: number } | undefined; +} + +export interface WebviewResourceResponse { + readonly requestId: number; + readonly status: number; + readonly mime: string | undefined; + readonly etag: string | undefined; + readonly mtime: number | undefined; + readonly size: number | undefined; + readonly range: string | undefined; +} diff --git a/src/vs/platform/webview/electron-main/webviewMainService.ts b/src/vs/platform/webview/electron-main/webviewMainService.ts index ddef3b74ee2939..075571b5fe9a96 100644 --- a/src/vs/platform/webview/electron-main/webviewMainService.ts +++ b/src/vs/platform/webview/electron-main/webviewMainService.ts @@ -6,7 +6,7 @@ import { WebContents, webContents, WebFrameMain } from 'electron'; import { Emitter } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; -import { FindInFrameOptions, FoundInFrameResult, IWebviewManagerService, WebviewDocumentRegistration, WebviewWebContentsId, WebviewWindowId } from '../common/webviewManagerService.js'; +import { FindInFrameOptions, FoundInFrameResult, IWebviewManagerService, WebviewDocumentRegistration, WebviewResourceRequest, WebviewResourceResponse, WebviewWebContentsId, WebviewWindowId } from '../common/webviewManagerService.js'; import { WebviewProtocolProvider } from './webviewProtocolProvider.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { IFileService } from '../../files/common/files.js'; @@ -17,6 +17,10 @@ export class WebviewMainService extends Disposable implements IWebviewManagerSer private readonly _onFoundInFrame = this._register(new Emitter()); public readonly onFoundInFrame = this._onFoundInFrame.event; + private readonly _onDidRequestWebviewResource = this._register(new Emitter()); + public readonly onDidRequestWebviewResource = this._onDidRequestWebviewResource.event; + private readonly _onDidCancelWebviewResource = this._register(new Emitter()); + public readonly onDidCancelWebviewResource = this._onDidCancelWebviewResource.event; private readonly protocolProvider: WebviewProtocolProvider; constructor( @@ -24,7 +28,11 @@ export class WebviewMainService extends Disposable implements IWebviewManagerSer @IWindowsMainService private readonly windowsMainService: IWindowsMainService, ) { super(); - this.protocolProvider = this._register(new WebviewProtocolProvider(fileService)); + this.protocolProvider = this._register(new WebviewProtocolProvider( + request => this._onDidRequestWebviewResource.fire(request), + requestId => this._onDidCancelWebviewResource.fire(requestId), + fileService, + )); } public async registerWebviewDocument(document: WebviewDocumentRegistration): Promise { @@ -35,6 +43,18 @@ export class WebviewMainService extends Disposable implements IWebviewManagerSer this.protocolProvider.unregisterWebviewDocument(extensionId, webviewId); } + public async startWebviewResourceResponse(response: WebviewResourceResponse): Promise { + this.protocolProvider.startResourceResponse(response); + } + + public async streamWebviewResourceResponse(requestId: number, data: import('../../../base/common/buffer.js').VSBuffer): Promise { + this.protocolProvider.streamResourceResponse(requestId, data); + } + + public async endWebviewResourceResponse(requestId: number, error?: boolean): Promise { + this.protocolProvider.endResourceResponse(requestId, error); + } + public async setIgnoreMenuShortcuts(id: WebviewWebContentsId | WebviewWindowId, enabled: boolean): Promise { let contents: WebContents | undefined; diff --git a/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts b/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts index fdd1db9dac509e..6edb5a76ebfa25 100644 --- a/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts +++ b/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts @@ -4,12 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { protocol } from 'electron'; +import { VSBuffer } from '../../../base/common/buffer.js'; import { IDisposable } from '../../../base/common/lifecycle.js'; import { AppResourcePath, COI, FileAccess, Schemas } from '../../../base/common/network.js'; -import { URI, UriComponents } from '../../../base/common/uri.js'; +import { URI } from '../../../base/common/uri.js'; import { IFileService } from '../../files/common/files.js'; -import { getWebviewContentMimeType } from '../common/mimeTypes.js'; -import { WebviewDocumentRegistration } from '../common/webviewManagerService.js'; +import { WebviewDocumentRegistration, WebviewResourceRequest, WebviewResourceResponse } from '../common/webviewManagerService.js'; export class WebviewProtocolProvider implements IDisposable { @@ -20,8 +20,16 @@ export class WebviewProtocolProvider implements IDisposable { ['/fake.html', { mime: 'text/html' }], ['/service-worker.js', { mime: 'application/javascript' }], ]); + private readonly pendingResources = new Map void; + readonly method: string; + controller: ReadableStreamDefaultController | undefined; + }>(); + private nextRequestId = 1; constructor( + private readonly requestResource: (request: WebviewResourceRequest) => void, + private readonly cancelResource: (requestId: number) => void, @IFileService private readonly _fileService: IFileService ) { // Register the protocol for loading webview html @@ -31,9 +39,18 @@ export class WebviewProtocolProvider implements IDisposable { dispose(): void { protocol.unhandle(Schemas.vscodeWebview); + for (const [requestId, pending] of this.pendingResources) { + pending.resolve(new Response(null, { status: 410 })); + this.cancelResource(requestId); + } + this.pendingResources.clear(); + WebviewProtocolProvider.documents.clear(); } public registerWebviewDocument(document: WebviewDocumentRegistration): void { + if (!/^[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*$/.test(document.extensionId.toLowerCase()) || !document.webviewId || document.webviewId.includes('/')) { + throw new Error('Invalid direct webview route'); + } WebviewProtocolProvider.documents.set(this.documentKey(document.extensionId, document.webviewId), document); } @@ -41,6 +58,54 @@ export class WebviewProtocolProvider implements IDisposable { WebviewProtocolProvider.documents.delete(this.documentKey(extensionId, webviewId)); } + public startResourceResponse(response: WebviewResourceResponse): void { + const pending = this.pendingResources.get(response.requestId); + if (!pending) { + return; + } + const headers: Record = { + 'Access-Control-Allow-Origin': '*', + 'Cross-Origin-Resource-Policy': 'cross-origin', + 'X-Content-Type-Options': 'nosniff', + }; + if (response.mime) { headers['Content-Type'] = response.mime; } + if (response.etag) { headers['ETag'] = response.etag; } + if (response.mtime !== undefined) { headers['Last-Modified'] = new Date(response.mtime).toUTCString(); } + if (response.range) { headers['Content-Range'] = response.range; headers['Accept-Ranges'] = 'bytes'; } + if (response.size !== undefined) { headers['Content-Length'] = String(response.range ? this.rangeLength(response.range) : response.size); } + + if (pending.method === 'HEAD' || response.status === 304 || response.status >= 400) { + this.pendingResources.delete(response.requestId); + pending.resolve(new Response(null, { status: response.status, headers })); + return; + } + const body = new ReadableStream({ + start: controller => pending.controller = controller, + cancel: () => { + this.pendingResources.delete(response.requestId); + this.cancelResource(response.requestId); + }, + }); + pending.resolve(new Response(body, { status: response.status, headers })); + } + + public streamResourceResponse(requestId: number, data: VSBuffer): void { + this.pendingResources.get(requestId)?.controller?.enqueue(data.buffer); + } + + public endResourceResponse(requestId: number, error?: boolean): void { + const pending = this.pendingResources.get(requestId); + if (!pending) { + return; + } + this.pendingResources.delete(requestId); + if (error) { + pending.controller?.error(new Error('Webview resource read failed')); + } else { + pending.controller?.close(); + } + } + public static getWebviewDocument(url: URI): WebviewDocumentRegistration | undefined { const match = /^\/([^/]+)\/(?:index\.html|_vscode\/resource\/)/.exec(url.path); if (!match) { @@ -115,22 +180,44 @@ export class WebviewProtocolProvider implements IDisposable { } const resource = this.decodeResourceUri(match[3]); - if (!resource || !this.isAllowedResource(resource, document.roots)) { + if (!resource) { return new Response(null, { status: 403 }); } - try { - const content = await this._fileService.readFile(resource); - return new Response(request.method === 'HEAD' ? null : content.value.buffer as ArrayBufferView, { - headers: { - 'Content-Type': getWebviewContentMimeType(resource), - 'Access-Control-Allow-Origin': '*', - 'Cross-Origin-Resource-Policy': 'cross-origin', - 'X-Content-Type-Options': 'nosniff', + if (this.pendingResources.size >= 128) { + return new Response(null, { status: 429 }); + } + const requestId = this.nextRequestId++; + const range = this.parseRange(request.headers.get('range')); + return new Promise(resolve => { + this.pendingResources.set(requestId, { resolve, method: request.method, controller: undefined }); + request.signal.addEventListener('abort', () => { + if (this.pendingResources.delete(requestId)) { + this.cancelResource(requestId); + resolve(new Response(null, { status: 499 })); } + }, { once: true }); + this.requestResource({ + requestId, + extensionId: document.extensionId, + webviewId: document.webviewId, + method: request.method as 'GET' | 'HEAD', + uri: resource, + ifNoneMatch: request.headers.get('if-none-match') ?? undefined, + range, }); - } catch { - return new Response(null, { status: 404 }); - } + }); + } + + private parseRange(value: string | null): { start: number; end?: number } | undefined { + if (!value) { return undefined; } + const match = /^bytes=(\d+)-(\d*)$/.exec(value); + if (!match) { return undefined; } + return { start: Number(match[1]), end: match[2] ? Number(match[2]) : undefined }; + } + + private rangeLength(value: string): number { + const match = /^bytes (\d+)-(\d+)\//.exec(value); + return match ? Number(match[2]) - Number(match[1]) + 1 : 0; } private decodeResourceUri(value: string): URI | undefined { @@ -155,17 +242,4 @@ export class WebviewProtocolProvider implements IDisposable { return authority.replace(/-([0-9a-f]{4})/g, (_, code) => String.fromCharCode(parseInt(code, 16))); } - private isAllowedResource(resource: URI, roots: readonly UriComponents[]): boolean { - for (const rawRoot of roots) { - const root = URI.revive(rawRoot); - const normalizedRoot = root.path.endsWith('/') ? root.path : `${root.path}/`; - if (root.scheme === resource.scheme - && root.authority === resource.authority - && resource.path.startsWith(normalizedRoot) - && !resource.path.split('/').includes('..')) { - return true; - } - } - return false; - } } diff --git a/src/vs/workbench/contrib/webview/browser/resourceLoading.ts b/src/vs/workbench/contrib/webview/browser/resourceLoading.ts index 93aee448b07ee8..845e463c3f252d 100644 --- a/src/vs/workbench/contrib/webview/browser/resourceLoading.ts +++ b/src/vs/workbench/contrib/webview/browser/resourceLoading.ts @@ -67,6 +67,34 @@ export async function loadLocalResource( return WebviewResourceResponse.AccessDenied; } + // File providers that expose real paths let us close the gap between lexical URI + // containment and the path that is actually read (for example a symlink from an + // extension's media directory to /etc/passwd). Providers without realpath retain + // the existing URI-identity containment check above. + const requestWithoutQuery = requestUri.with({ query: '', fragment: '' }); + const canonicalResource = await realpath(fileService, requestWithoutQuery); + if (canonicalResource) { + let checkedCanonicalRoot = false; + let containedByCanonicalRoot = false; + for (const root of options.roots) { + if (!containsResource(root, requestWithoutQuery, uriIdentityService)) { + continue; + } + const canonicalRoot = await realpath(fileService, root); + if (canonicalRoot) { + checkedCanonicalRoot = true; + if (uriIdentityService.extUri.isEqualOrParent(canonicalResource, canonicalRoot, true)) { + containedByCanonicalRoot = true; + break; + } + } + } + if (checkedCanonicalRoot && !containedByCanonicalRoot) { + logService.warn(`Webview.loadLocalResource - access denied after realpath check. requestUri=${requestUri}, canonicalResource=${canonicalResource}`); + return WebviewResourceResponse.AccessDenied; + } + } + const mime = getWebviewContentMimeType(requestUri); // Use the original path for the mime try { @@ -100,6 +128,14 @@ export async function loadLocalResource( } } +async function realpath(fileService: IFileService, resource: URI): Promise { + try { + return await fileService.realpath(resource); + } catch { + return undefined; + } +} + export function getResourceToLoad( requestUri: URI, roots: ReadonlyArray, diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index 5fd4345e073dc6..5a1d1e2e501d7b 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -541,6 +541,9 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi } if (e.data.channel === 'webview-ready') { + if (!this.isValidWebviewReady(e.data.data)) { + return; + } if (this._messagePort) { return; } @@ -570,6 +573,8 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi })); } + protected isValidWebviewReady(_data: unknown): boolean { return true; } + protected prepareForDirectNavigation(targetWindow: CodeWindow): void { this._messagePort = undefined; const pending = this._state.type === WebviewState.Type.Initializing ? this._state.pendingMessages : []; @@ -956,6 +961,14 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi }); } + protected loadDirectResource(uri: URI, options: { ifNoneMatch: string | undefined; range?: { readonly start: number; readonly end?: number } }, token: CancellationToken): Promise { + return this._instantiationService.invokeFunction(loadLocalResource, uri, { + ifNoneMatch: options.ifNoneMatch, + roots: this._content.options.localResourceRoots || [], + range: options.range, + }, token); + } + private async localLocalhost(id: string, origin: string) { const authority = this._environmentService.remoteAuthority; const resolveAuthority = authority ? await this._remoteAuthorityResolverService.resolveAuthority(authority) : undefined; diff --git a/src/vs/workbench/contrib/webview/common/webview.ts b/src/vs/workbench/contrib/webview/common/webview.ts index f934a84e4ba9e9..b3f163a74c060a 100644 --- a/src/vs/workbench/contrib/webview/common/webview.ts +++ b/src/vs/workbench/contrib/webview/common/webview.ts @@ -29,6 +29,13 @@ export interface ElectronWebviewResourceRoute { readonly webviewId: string; } +const webviewExtensionIdPattern = /^[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*$/; + +export function normalizeWebviewExtensionId(extensionId: string): string | undefined { + const normalized = extensionId.toLowerCase(); + return webviewExtensionIdPattern.test(normalized) ? normalized : undefined; +} + /** * Construct a uri that can load resources inside a webview * @@ -56,10 +63,14 @@ export function asWebviewUri(resource: URI, remoteInfo?: WebviewRemoteInfo, elec } if (electronRoute) { + const extensionId = normalizeWebviewExtensionId(electronRoute.extensionId); + if (!extensionId) { + throw new Error(`Invalid extension id for webview route: ${electronRoute.extensionId}`); + } return URI.from({ scheme: Schemas.vscodeWebview, - authority: electronRoute.extensionId.toLowerCase(), - path: `/${encodeURIComponent(electronRoute.webviewId)}/_vscode/resource/${resource.scheme}+${encodeAuthority(resource.authority)}${resource.path}`, + authority: extensionId, + path: `/${electronRoute.webviewId}/_vscode/resource/${resource.scheme}+${encodeAuthority(resource.authority)}${resource.path}`, fragment: resource.fragment, query: resource.query, }); diff --git a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts index 4ba8f5209c3224..059e284aa8bc03 100644 --- a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts @@ -4,6 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { Delayer } from '../../../../base/common/async.js'; +import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { listenStream } from '../../../../base/common/stream.js'; +import { URI } from '../../../../base/common/uri.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; import { CodeWindow } from '../../../../base/browser/window.js'; import { createTrustedTypesPolicy } from '../../../../base/browser/trustedTypes.js'; import { Schemas } from '../../../../base/common/network.js'; @@ -18,11 +22,12 @@ import { INativeHostService } from '../../../../platform/native/common/native.js import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IRemoteAuthorityResolverService } from '../../../../platform/remote/common/remoteAuthorityResolver.js'; import { ITunnelService } from '../../../../platform/tunnel/common/tunnel.js'; -import { FindInFrameOptions, IWebviewManagerService } from '../../../../platform/webview/common/webviewManagerService.js'; +import { FindInFrameOptions, IWebviewManagerService, WebviewResourceRequest } from '../../../../platform/webview/common/webviewManagerService.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { WebviewThemeDataProvider } from '../browser/themeing.js'; import { WebviewInitInfo } from '../browser/webview.js'; import { WebviewElement } from '../browser/webviewElement.js'; +import { WebviewResourceResponse } from '../browser/resourceLoading.js'; import { WindowIgnoreMenuShortcutsManager } from './windowIgnoreMenuShortcutsManager.js'; const singleIframeBootstrap = String.raw`(() => { @@ -75,15 +80,35 @@ const singleIframeBootstrap = String.raw`(() => { window.addEventListener('blur', () => post('did-blur')); window.addEventListener('scroll', () => post('did-scroll', { scrollYPercentage: document.body.scrollHeight ? scrollY / document.body.scrollHeight : 0 }), { passive: true }); window.addEventListener('wheel', event => post('did-scroll-wheel', { deltaMode: event.deltaMode, deltaX: event.deltaX, deltaY: event.deltaY, deltaZ: event.deltaZ }), { passive: true }); + const keyData = event => ({ key: event.key, keyCode: event.keyCode, code: event.code, shiftKey: event.shiftKey, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, repeat: event.repeat, isTrusted: event.isTrusted }); + window.addEventListener('keydown', event => post('did-keydown', keyData(event))); + window.addEventListener('keyup', event => post('did-keyup', keyData(event))); + window.addEventListener('dragenter', () => post('drag-start')); + window.addEventListener('dragover', event => post('drag', { shiftKey: event.shiftKey })); + window.addEventListener('contextmenu', event => post('did-context-menu', { clientX: event.clientX, clientY: event.clientY, context: {} })); document.addEventListener('click', event => { const anchor = event.target instanceof Element ? event.target.closest('a[href]') : null; if (anchor) { event.preventDefault(); post('did-click-link', { uri: anchor.href }); } }); - parent.postMessage({ target, channel: 'webview-ready' }, parentOrigin, [channel.port2]); + parent.postMessage({ target, channel: 'webview-ready', data: { revision: Number(params.get('revision')), nonce: params.get('nonce') } }, parentOrigin, [channel.port2]); })();`; -const singleIframeHtmlPolicy = createTrustedTypesPolicy('singleIframeWebview', { createHTML: value => value }); +const singleIframeDefaultStyles = `@layer vscode-default { + html { scrollbar-color: var(--vscode-scrollbarSlider-background) var(--vscode-editor-background); } + body { overscroll-behavior-x: none; background-color: transparent; color: var(--vscode-editor-foreground); font-family: var(--vscode-font-family); font-weight: var(--vscode-font-weight); font-size: var(--vscode-font-size); margin: 0; padding: 0 20px; } + img, video { max-width: 100%; max-height: 100%; } + a, a code { color: var(--vscode-textLink-foreground); } + a:hover { color: var(--vscode-textLink-activeForeground); } + a:focus, input:focus, select:focus, textarea:focus { outline: 1px solid -webkit-focus-ring-color; outline-offset: -1px; } + code { font-family: var(--monaco-monospace-font); color: var(--vscode-textPreformat-foreground); background-color: var(--vscode-textPreformat-background); padding: 1px 3px; border-radius: 4px; } + pre code { padding: 0; } +}`; + +const singleIframeHtmlPolicy = createTrustedTypesPolicy('singleIframeWebview', { + createHTML: value => value, + createScript: value => value, +}); /** * Webview backed by an iframe but that uses Electron APIs to power the webview. @@ -99,8 +124,10 @@ export class ElectronWebviewElement extends WebviewElement { private readonly _iframeDelayer = this._register(new Delayer(200)); private _directTargetWindow: CodeWindow | undefined; private _directRevision = 0; + private _directNonce: string | undefined; private _directContentKey: string | undefined; private _directUpdate: Promise = Promise.resolve(); + private readonly _directResourceRequests = new Map(); protected override get platform() { return 'electron'; } @@ -126,6 +153,14 @@ export class ElectronWebviewElement extends WebviewElement { this._webviewKeyboardHandler = new WindowIgnoreMenuShortcutsManager(configurationService, mainProcessService, _nativeHostService); this._webviewMainService = ProxyChannel.toService(mainProcessService.getChannel('webview')); + this._register(this._webviewMainService.onDidRequestWebviewResource(request => this.handleDirectResourceRequest(request))); + this._register(this._webviewMainService.onDidCancelWebviewResource(requestId => { + const request = this._directResourceRequests.get(requestId); + if (request) { + this._directResourceRequests.delete(requestId); + request.dispose(true); + } + })); if (initInfo.options.enableFindWidget) { this._register(this.onDidHtmlChange((newContent) => { @@ -148,9 +183,77 @@ export class ElectronWebviewElement extends WebviewElement { if (this.extension?.useSingleIframe && this.resourceId) { void this._webviewMainService.unregisterWebviewDocument(this.extension.id.value, this.resourceId); } + for (const request of this._directResourceRequests.values()) { + request.dispose(true); + } + this._directResourceRequests.clear(); super.dispose(); } + private async handleDirectResourceRequest(request: WebviewResourceRequest): Promise { + if (!this.useSingleIframe + || request.extensionId.toLowerCase() !== this.extension?.id.value.toLowerCase() + || request.webviewId !== this.resourceId) { + return; + } + const cts = new CancellationTokenSource(); + this._directResourceRequests.set(request.requestId, cts); + let streaming = false; + const finish = () => { + if (this._directResourceRequests.delete(request.requestId)) { + cts.dispose(); + } + }; + try { + const result = await this.loadDirectResource(URI.revive(request.uri), { + ifNoneMatch: request.ifNoneMatch, + range: request.range, + }, cts.token); + if (cts.token.isCancellationRequested) { + return; + } + switch (result.type) { + case WebviewResourceResponse.Type.Success: { + const requestedEnd = request.range?.end ?? result.size - 1; + const rangeEnd = Math.min(requestedEnd, result.size - 1); + await this._webviewMainService.startWebviewResourceResponse({ + requestId: request.requestId, + status: request.range ? 206 : 200, + mime: result.mimeType, + etag: result.etag, + mtime: result.mtime, + size: result.size, + range: request.range ? `bytes ${request.range.start}-${rangeEnd}/${result.size}` : undefined, + }); + if (request.method === 'HEAD') { + return; + } + streaming = true; + listenStream(result.stream, { + onData: data => void this._webviewMainService.streamWebviewResourceResponse(request.requestId, data), + onError: () => { void this._webviewMainService.endWebviewResourceResponse(request.requestId, true); finish(); }, + onEnd: () => { void this._webviewMainService.endWebviewResourceResponse(request.requestId); finish(); }, + }, cts.token); + return; + } + case WebviewResourceResponse.Type.NotModified: + await this._webviewMainService.startWebviewResourceResponse({ requestId: request.requestId, status: 304, mime: result.mimeType, etag: undefined, mtime: result.mtime, size: undefined, range: undefined }); + return; + case WebviewResourceResponse.Type.AccessDenied: + await this._webviewMainService.startWebviewResourceResponse({ requestId: request.requestId, status: 401, mime: undefined, etag: undefined, mtime: undefined, size: undefined, range: undefined }); + return; + default: + await this._webviewMainService.startWebviewResourceResponse({ requestId: request.requestId, status: 404, mime: undefined, etag: undefined, mtime: undefined, size: undefined, range: undefined }); + } + } catch { + await this._webviewMainService.startWebviewResourceResponse({ requestId: request.requestId, status: 404, mime: undefined, etag: undefined, mtime: undefined, size: undefined, range: undefined }); + } finally { + if (!streaming || cts.token.isCancellationRequested) { + finish(); + } + } + } + public override reload(): void { if (!this.useSingleIframe) { super.reload(); @@ -181,6 +284,14 @@ export class ElectronWebviewElement extends WebviewElement { } } + protected override isValidWebviewReady(data: unknown): boolean { + return !this.useSingleIframe + || (typeof data === 'object' + && data !== null + && (data as { revision?: number }).revision === this._directRevision + && (data as { nonce?: string }).nonce === this._directNonce); + } + private async updateDirectDocument(prepareForNavigation: boolean): Promise { const extensionId = this.extension?.id.value.toLowerCase(); const webviewId = this.resourceId; @@ -202,6 +313,8 @@ export class ElectronWebviewElement extends WebviewElement { this._directUpdate = this._directUpdate.then(async () => { const revision = ++this._directRevision; + const navigationNonce = generateUuid(); + this._directNonce = navigationNonce; const transformed = await this.transformDirectHtml(this.content.html, !!this.content.options.allowScripts); if (revision !== this._directRevision) { return; @@ -233,6 +346,7 @@ export class ElectronWebviewElement extends WebviewElement { } const query = new URLSearchParams({ revision: String(revision), + nonce: navigationNonce, target: this.id, parentOrigin: targetWindow.origin, }); @@ -254,14 +368,20 @@ export class ElectronWebviewElement extends WebviewElement { } let csp = policies[0].getAttribute('content')!.trim(); policies[0].remove(); - const hash = await this.bootstrapHash(); - csp = this.addBootstrapHash(csp, hash); + const hash = await this.contentHash(singleIframeBootstrap); + const styleHash = await this.contentHash(singleIframeDefaultStyles); + csp = this.addHash(csp, 'script-src', hash); + csp = this.addHash(csp, 'style-src', styleHash); if (!allowScripts) { csp += `, script-src ${hash}; script-src-attr 'none'`; } const script = document.createElement('script'); - script.textContent = singleIframeBootstrap; + script.text = (singleIframeHtmlPolicy?.createScript?.(singleIframeBootstrap) ?? singleIframeBootstrap) as string; document.head.prepend(script); + const defaultStyles = document.createElement('style'); + defaultStyles.id = '_defaultStyles'; + defaultStyles.textContent = singleIframeDefaultStyles; + document.head.prepend(defaultStyles); const state = document.createElement('meta'); state.name = 'vscode-webview-state'; state.content = this.content.state ? encodeURIComponent(this.content.state) : ''; @@ -270,20 +390,20 @@ export class ElectronWebviewElement extends WebviewElement { return { html: `\n${document.documentElement.outerHTML}`, csp }; } - private async bootstrapHash(): Promise { - const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(singleIframeBootstrap))); + private async contentHash(value: string): Promise { + const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))); let binary = ''; for (const value of digest) { binary += String.fromCharCode(value); } return `'sha256-${btoa(binary)}'`; } - private addBootstrapHash(csp: string, hash: string): string { + private addHash(csp: string, directive: string, hash: string): string { const directives = csp.split(';').map(value => value.trim()).filter(Boolean); - const scriptIndex = directives.findIndex(value => value.toLowerCase().startsWith('script-src ')); - if (scriptIndex >= 0) { - directives[scriptIndex] += ` ${hash}`; + const index = directives.findIndex(value => value.toLowerCase().startsWith(`${directive} `)); + if (index >= 0) { + directives[index] += ` ${hash}`; } else { - directives.push(`script-src ${hash}`); + directives.push(`${directive} ${hash}`); } return directives.join('; '); } diff --git a/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts b/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts index 2ae1d202ac77ed..5e064bf5ffdc8c 100644 --- a/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts +++ b/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { isWindows } from '../../../../../base/common/platform.js'; +import { Schemas } from '../../../../../base/common/network.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { FileService } from '../../../../../platform/files/common/fileService.js'; @@ -13,6 +14,7 @@ import { ILogService, NullLogService } from '../../../../../platform/log/common/ import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; import { UriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentityService.js'; import { getResourceToLoad } from '../../browser/resourceLoading.js'; +import { asWebviewUri, normalizeWebviewExtensionId } from '../../common/webview.js'; suite('Webview Resource Loading - getResourceToLoad', () => { const disposableStore = ensureNoDisposablesAreLeakedInTestSuite(); @@ -241,3 +243,29 @@ suite('Webview Resource Loading - getResourceToLoad', () => { }); }); }); + +suite('Webview Resource Loading - direct Electron routes', () => { + test('normalizes and validates extension identifiers', () => { + assert.strictEqual(normalizeWebviewExtensionId('Publisher.Extension-Name'), 'publisher.extension-name'); + assert.strictEqual(normalizeWebviewExtensionId('missingPublisher'), undefined); + assert.strictEqual(normalizeWebviewExtensionId('publisher.extension/other'), undefined); + }); + + test('constructs readable instance routes and preserves URI components', () => { + const result = asWebviewUri( + URI.from({ scheme: 'test-provider', authority: 'remote+host', path: '/目录/a file.css', query: 'v=1', fragment: 'rule' }), + undefined, + { extensionId: 'Publisher.Extension', webviewId: 'instance id' }, + ); + assert.strictEqual(result.scheme, Schemas.vscodeWebview); + assert.strictEqual(result.authority, 'publisher.extension'); + assert.strictEqual(result.path, '/instance id/_vscode/resource/test-provider+remote-002bhost/目录/a file.css'); + assert.strictEqual(result.query, 'v=1'); + assert.strictEqual(result.fragment, 'rule'); + }); + + test('leaves network resources unchanged', () => { + const resource = URI.parse('https://example.com/image.png?q=1#fragment'); + assert.strictEqual(asWebviewUri(resource, undefined, { extensionId: 'publisher.extension', webviewId: 'instance' }), resource); + }); +}); From eb9c3c1feda981777dfefe1b9f6bf0aba9f81b70 Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Sat, 11 Jul 2026 21:50:12 +0000 Subject: [PATCH 03/12] try some things --- .../webview-no-service-worker/extension.js | 18 ++++- src/vs/code/electron-main/app.ts | 7 ++ .../webview/common/webviewManagerService.ts | 9 +++ .../electron-main/webviewMainService.ts | 10 ++- .../electron-main/webviewProtocolProvider.ts | 79 ++++++++++++++++++- .../api/test/browser/extHostWebview.test.ts | 34 +++++++- .../contrib/webview/browser/webviewElement.ts | 12 ++- .../electron-browser/webviewElement.ts | 23 +++++- 8 files changed, 177 insertions(+), 15 deletions(-) diff --git a/extensions/webview-no-service-worker/extension.js b/extensions/webview-no-service-worker/extension.js index be98633d32da1f..29436506c0e0e5 100644 --- a/extensions/webview-no-service-worker/extension.js +++ b/extensions/webview-no-service-worker/extension.js @@ -24,14 +24,22 @@ function activate(context) {

Single-iframe webview

- VS Code test image + VS Code test image

Waiting for VS Code API…

`; panel.webview.onDidReceiveMessage(message => { if (message.type === 'ready') { + void panel.webview.postMessage({ type: 'pong' }); void vscode.window.setStatusBarMessage('Single-iframe webview ready', 3000); } }); diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 0dd98cbab792ea..1addbf4d0ddb7d 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -374,6 +374,13 @@ export class CodeApplication extends Disposable { session.defaultSession.webRequest.onBeforeRequest((details, callback) => { const uri = URI.parse(details.url); + if ((uri.scheme === Schemas.http || uri.scheme === Schemas.https) && details.frame) { + const portMapping = WebviewProtocolProvider.getWebviewPortMapping(details.frame.url, details.url); + if (portMapping) { + void portMapping.then(redirectURL => callback(redirectURL ? { redirectURL } : { cancel: false })); + return; + } + } if (uri.scheme === Schemas.vscodeWebview) { if (!isAllowedWebviewRequest(uri, details)) { this.logService.error('Blocked vscode-webview request', details.url); diff --git a/src/vs/platform/webview/common/webviewManagerService.ts b/src/vs/platform/webview/common/webviewManagerService.ts index 6907376d54df3d..74e9eb02ffe134 100644 --- a/src/vs/platform/webview/common/webviewManagerService.ts +++ b/src/vs/platform/webview/common/webviewManagerService.ts @@ -37,12 +37,14 @@ export interface IWebviewManagerService { readonly onFoundInFrame: Event; readonly onDidRequestWebviewResource: Event; readonly onDidCancelWebviewResource: Event; + readonly onDidRequestWebviewPortMapping: Event; registerWebviewDocument(document: WebviewDocumentRegistration): Promise; unregisterWebviewDocument(extensionId: string, webviewId: string): Promise; startWebviewResourceResponse(response: WebviewResourceResponse): Promise; streamWebviewResourceResponse(requestId: number, data: VSBuffer): Promise; endWebviewResourceResponse(requestId: number, error?: boolean): Promise; + resolveWebviewPortMapping(requestId: number, redirect: string | undefined): Promise; setIgnoreMenuShortcuts(id: WebviewWebContentsId | WebviewWindowId, enabled: boolean): Promise; @@ -80,3 +82,10 @@ export interface WebviewResourceResponse { readonly size: number | undefined; readonly range: string | undefined; } + +export interface WebviewPortMappingRequest { + readonly requestId: number; + readonly extensionId: string; + readonly webviewId: string; + readonly origin: string; +} diff --git a/src/vs/platform/webview/electron-main/webviewMainService.ts b/src/vs/platform/webview/electron-main/webviewMainService.ts index 075571b5fe9a96..c735270c608215 100644 --- a/src/vs/platform/webview/electron-main/webviewMainService.ts +++ b/src/vs/platform/webview/electron-main/webviewMainService.ts @@ -6,7 +6,7 @@ import { WebContents, webContents, WebFrameMain } from 'electron'; import { Emitter } from '../../../base/common/event.js'; import { Disposable } from '../../../base/common/lifecycle.js'; -import { FindInFrameOptions, FoundInFrameResult, IWebviewManagerService, WebviewDocumentRegistration, WebviewResourceRequest, WebviewResourceResponse, WebviewWebContentsId, WebviewWindowId } from '../common/webviewManagerService.js'; +import { FindInFrameOptions, FoundInFrameResult, IWebviewManagerService, WebviewDocumentRegistration, WebviewPortMappingRequest, WebviewResourceRequest, WebviewResourceResponse, WebviewWebContentsId, WebviewWindowId } from '../common/webviewManagerService.js'; import { WebviewProtocolProvider } from './webviewProtocolProvider.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { IFileService } from '../../files/common/files.js'; @@ -21,6 +21,8 @@ export class WebviewMainService extends Disposable implements IWebviewManagerSer public readonly onDidRequestWebviewResource = this._onDidRequestWebviewResource.event; private readonly _onDidCancelWebviewResource = this._register(new Emitter()); public readonly onDidCancelWebviewResource = this._onDidCancelWebviewResource.event; + private readonly _onDidRequestWebviewPortMapping = this._register(new Emitter()); + public readonly onDidRequestWebviewPortMapping = this._onDidRequestWebviewPortMapping.event; private readonly protocolProvider: WebviewProtocolProvider; constructor( @@ -31,8 +33,10 @@ export class WebviewMainService extends Disposable implements IWebviewManagerSer this.protocolProvider = this._register(new WebviewProtocolProvider( request => this._onDidRequestWebviewResource.fire(request), requestId => this._onDidCancelWebviewResource.fire(requestId), + request => this._onDidRequestWebviewPortMapping.fire(request), fileService, )); + this._register(this.windowsMainService.onDidDestroyWindow(window => this.protocolProvider.unregisterWebviewWindow(window.id))); } public async registerWebviewDocument(document: WebviewDocumentRegistration): Promise { @@ -55,6 +59,10 @@ export class WebviewMainService extends Disposable implements IWebviewManagerSer this.protocolProvider.endResourceResponse(requestId, error); } + public async resolveWebviewPortMapping(requestId: number, redirect: string | undefined): Promise { + this.protocolProvider.resolvePortMapping(requestId, redirect); + } + public async setIgnoreMenuShortcuts(id: WebviewWebContentsId | WebviewWindowId, enabled: boolean): Promise { let contents: WebContents | undefined; diff --git a/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts b/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts index 6edb5a76ebfa25..0b05ee6f258322 100644 --- a/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts +++ b/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts @@ -9,10 +9,11 @@ import { IDisposable } from '../../../base/common/lifecycle.js'; import { AppResourcePath, COI, FileAccess, Schemas } from '../../../base/common/network.js'; import { URI } from '../../../base/common/uri.js'; import { IFileService } from '../../files/common/files.js'; -import { WebviewDocumentRegistration, WebviewResourceRequest, WebviewResourceResponse } from '../common/webviewManagerService.js'; +import { WebviewDocumentRegistration, WebviewPortMappingRequest, WebviewResourceRequest, WebviewResourceResponse } from '../common/webviewManagerService.js'; export class WebviewProtocolProvider implements IDisposable { + private static instance: WebviewProtocolProvider | undefined; private static readonly documents = new Map(); private static validWebviewFilePaths = new Map([ @@ -23,18 +24,23 @@ export class WebviewProtocolProvider implements IDisposable { private readonly pendingResources = new Map void; readonly method: string; + readonly extensionId: string; + readonly webviewId: string; controller: ReadableStreamDefaultController | undefined; }>(); private nextRequestId = 1; + private readonly pendingPortMappings = new Map void>(); constructor( private readonly requestResource: (request: WebviewResourceRequest) => void, private readonly cancelResource: (requestId: number) => void, + private readonly requestPortMapping: (request: WebviewPortMappingRequest) => void, @IFileService private readonly _fileService: IFileService ) { // Register the protocol for loading webview html const webviewHandler = this.handleWebviewRequest.bind(this); protocol.handle(Schemas.vscodeWebview, webviewHandler); + WebviewProtocolProvider.instance = this; } dispose(): void { @@ -45,6 +51,9 @@ export class WebviewProtocolProvider implements IDisposable { } this.pendingResources.clear(); WebviewProtocolProvider.documents.clear(); + for (const resolve of this.pendingPortMappings.values()) { resolve(undefined); } + this.pendingPortMappings.clear(); + if (WebviewProtocolProvider.instance === this) { WebviewProtocolProvider.instance = undefined; } } public registerWebviewDocument(document: WebviewDocumentRegistration): void { @@ -56,6 +65,22 @@ export class WebviewProtocolProvider implements IDisposable { public unregisterWebviewDocument(extensionId: string, webviewId: string): void { WebviewProtocolProvider.documents.delete(this.documentKey(extensionId, webviewId)); + for (const [requestId, pending] of this.pendingResources) { + if (pending.extensionId.toLowerCase() === extensionId.toLowerCase() && pending.webviewId === webviewId) { + this.pendingResources.delete(requestId); + pending.controller?.error(new Error('Webview disposed')); + pending.resolve(new Response(null, { status: 410 })); + this.cancelResource(requestId); + } + } + } + + public unregisterWebviewWindow(windowId: number): void { + for (const document of [...WebviewProtocolProvider.documents.values()]) { + if (document.windowId === windowId) { + this.unregisterWebviewDocument(document.extensionId, document.webviewId); + } + } } public startResourceResponse(response: WebviewResourceResponse): void { @@ -114,6 +139,45 @@ export class WebviewProtocolProvider implements IDisposable { return this.documents.get(`${url.authority.toLowerCase()}\0${decodeURIComponent(match[1])}`); } + public static getWebviewPortMapping(frameUrl: string, targetUrl: string): Promise | undefined { + const instance = this.instance; + if (!instance) { return undefined; } + let frameUri: URI; + let target: URL; + try { + frameUri = URI.parse(frameUrl); + target = new URL(targetUrl); + } catch { + return undefined; + } + const document = this.getWebviewDocument(frameUri); + if (!document || !['localhost', '127.0.0.1', '[::1]'].includes(target.hostname)) { + return undefined; + } + const route = `${Schemas.vscodeWebview}://${document.extensionId.toLowerCase()}/${encodeURIComponent(document.webviewId)}/`; + if (!frameUrl.startsWith(route)) { + return undefined; + } + return instance.requestPortMappingForDocument(document, target.origin); + } + + private requestPortMappingForDocument(document: WebviewDocumentRegistration, origin: string): Promise { + const requestId = this.nextRequestId++; + return new Promise(resolve => { + this.pendingPortMappings.set(requestId, resolve); + this.requestPortMapping({ requestId, extensionId: document.extensionId, webviewId: document.webviewId, origin }); + setTimeout(() => this.resolvePortMapping(requestId, undefined), 10_000); + }); + } + + public resolvePortMapping(requestId: number, redirect: string | undefined): void { + const resolve = this.pendingPortMappings.get(requestId); + if (resolve) { + this.pendingPortMappings.delete(requestId); + resolve(redirect); + } + } + private documentKey(extensionId: string, webviewId: string): string { return `${extensionId.toLowerCase()}\0${webviewId}`; } @@ -179,7 +243,7 @@ export class WebviewProtocolProvider implements IDisposable { }); } - const resource = this.decodeResourceUri(match[3]); + const resource = this.decodeResourceUri(match[3], uri.query); if (!resource) { return new Response(null, { status: 403 }); } @@ -189,7 +253,13 @@ export class WebviewProtocolProvider implements IDisposable { const requestId = this.nextRequestId++; const range = this.parseRange(request.headers.get('range')); return new Promise(resolve => { - this.pendingResources.set(requestId, { resolve, method: request.method, controller: undefined }); + this.pendingResources.set(requestId, { + resolve, + method: request.method, + extensionId: document.extensionId, + webviewId: document.webviewId, + controller: undefined, + }); request.signal.addEventListener('abort', () => { if (this.pendingResources.delete(requestId)) { this.cancelResource(requestId); @@ -220,7 +290,7 @@ export class WebviewProtocolProvider implements IDisposable { return match ? Number(match[2]) - Number(match[1]) + 1 : 0; } - private decodeResourceUri(value: string): URI | undefined { + private decodeResourceUri(value: string, query: string): URI | undefined { try { const slash = value.indexOf('/'); const encodedOrigin = slash < 0 ? value : value.slice(0, slash); @@ -232,6 +302,7 @@ export class WebviewProtocolProvider implements IDisposable { scheme: encodedOrigin.slice(0, plus), authority: this.decodeAuthority(encodedOrigin.slice(plus + 1)), path: slash < 0 ? '/' : value.slice(slash), + query, }); } catch { return undefined; diff --git a/src/vs/workbench/api/test/browser/extHostWebview.test.ts b/src/vs/workbench/api/test/browser/extHostWebview.test.ts index 6ae2519bd18b61..9719d24dcbc6ac 100644 --- a/src/vs/workbench/api/test/browser/extHostWebview.test.ts +++ b/src/vs/workbench/api/test/browser/extHostWebview.test.ts @@ -20,7 +20,7 @@ import { UIKind } from '../../../services/extensions/common/extensionHostProtoco import { ExtHostWebviewPanels } from '../../common/extHostWebviewPanels.js'; import { IExtHostWorkspace } from '../../common/extHostWorkspace.js'; import { SingleProxyRPCProtocol } from '../common/testRPCProtocol.js'; -import { decodeAuthority, webviewResourceBaseHost } from '../../../contrib/webview/common/webview.js'; +import { decodeAuthority, webviewGenericCspSource, webviewResourceBaseHost } from '../../../contrib/webview/common/webview.js'; import { EditorGroupColumn } from '../../../services/editor/common/editorGroupColumn.js'; import { IExtHostContext } from '../../../services/extensions/common/extHostCustomers.js'; import type * as vscode from 'vscode'; @@ -199,6 +199,38 @@ suite('ExtHostWebview', () => { ); }); + test('webviewNoServiceWorker is gated to proposed API on desktop', () => { + const extension = { + identifier: { value: 'Publisher.Extension' }, + extensionLocation: URI.file('/extension'), + enabledApiProposals: ['webviewNoServiceWorker'], + } as unknown as IExtensionDescription; + const desktopWebviews = disposables.add(new ExtHostWebviews(rpcProtocol!, { authority: undefined, isRemote: false }, UIKind.Desktop, undefined, new NullLogService(), NullApiDeprecationService)); + const desktopWebview = desktopWebviews.createNewWebview('readable-instance', {}, extension); + assert.strictEqual(desktopWebview.cspSource, 'vscode-webview://publisher.extension'); + assert.strictEqual( + desktopWebview.asWebviewUri(URI.file('/extension/media/icon.svg')).toString(), + 'vscode-webview://publisher.extension/readable-instance/_vscode/resource/file%2B/extension/media/icon.svg', + ); + + const webWebviews = disposables.add(new ExtHostWebviews(rpcProtocol!, { authority: undefined, isRemote: false }, UIKind.Web, undefined, new NullLogService(), NullApiDeprecationService)); + const webWebview = webWebviews.createNewWebview('readable-instance', {}, extension); + assert.strictEqual(webWebview.cspSource, webviewGenericCspSource); + assert.strictEqual(webWebview.asWebviewUri(URI.file('/extension/media/icon.svg')).scheme, Schemas.https); + }); + + test('webviewNoServiceWorker requires the proposal', () => { + const extension = { + identifier: { value: 'publisher.extension' }, + extensionLocation: URI.file('/extension'), + enabledApiProposals: [], + } as unknown as IExtensionDescription; + const extHostWebviews = disposables.add(new ExtHostWebviews(rpcProtocol!, { authority: undefined, isRemote: false }, UIKind.Desktop, undefined, new NullLogService(), NullApiDeprecationService)); + const webview = extHostWebviews.createNewWebview('instance', {}, extension); + assert.strictEqual(webview.cspSource, webviewGenericCspSource); + assert.strictEqual(webview.asWebviewUri(URI.file('/extension/media/icon.svg')).scheme, Schemas.https); + }); + suite('ensureDefaultContentOptions', () => { function createExtHostWebviewsWithCapture(workspaceFolders: URI[] | undefined) { const setOptionsCalls: { handle: string; options: IWebviewContentOptions }[] = []; diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index 5a1d1e2e501d7b..28bc0700104866 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -642,7 +642,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi } private _hasAlertedAboutMissingCsp = false; - private handleNoCspFound(): void { + protected handleNoCspFound(): void { if (this._hasAlertedAboutMissingCsp) { return; } @@ -970,9 +970,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi } private async localLocalhost(id: string, origin: string) { - const authority = this._environmentService.remoteAuthority; - const resolveAuthority = authority ? await this._remoteAuthorityResolverService.resolveAuthority(authority) : undefined; - const redirect = resolveAuthority ? await this._portMappingManager.getRedirect(resolveAuthority.authority, origin) : undefined; + const redirect = await this.getDirectLocalhostRedirect(origin); return this._send('did-load-localhost', { id, origin, @@ -980,6 +978,12 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi }); } + protected async getDirectLocalhostRedirect(origin: string): Promise { + const authority = this._environmentService.remoteAuthority; + const resolveAuthority = authority ? await this._remoteAuthorityResolverService.resolveAuthority(authority) : undefined; + return resolveAuthority ? this._portMappingManager.getRedirect(resolveAuthority.authority, origin) : undefined; + } + public focus(): void { this._doFocus(); diff --git a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts index 059e284aa8bc03..9529456609f508 100644 --- a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts @@ -52,12 +52,17 @@ const singleIframeBootstrap = String.raw`(() => { }); }; + let lastStyleData; const applyStyles = data => { + lastStyleData = data; for (const [key, value] of Object.entries(data.styles || {})) { document.documentElement.style.setProperty('--' + key, String(value)); } - document.body?.classList.remove('vscode-light', 'vscode-dark', 'vscode-high-contrast', 'vscode-high-contrast-light'); + if (!document.body) { return; } + document.body?.classList.remove('vscode-light', 'vscode-dark', 'vscode-high-contrast', 'vscode-high-contrast-light', 'vscode-reduce-motion', 'vscode-using-screen-reader'); if (data.activeTheme) { document.body?.classList.add(data.activeTheme); } + if (data.reduceMotion) { document.body?.classList.add('vscode-reduce-motion'); } + if (data.screenReader) { document.body?.classList.add('vscode-using-screen-reader'); } document.body?.setAttribute('data-vscode-theme-kind', data.activeTheme || ''); document.body?.setAttribute('data-vscode-theme-name', data.themeLabel || ''); document.body?.setAttribute('data-vscode-theme-id', data.themeId || ''); @@ -78,13 +83,16 @@ const singleIframeBootstrap = String.raw`(() => { window.addEventListener('focus', () => post('did-focus')); window.addEventListener('blur', () => post('did-blur')); + window.addEventListener('DOMContentLoaded', () => { if (lastStyleData) { applyStyles(lastStyleData); } }); window.addEventListener('scroll', () => post('did-scroll', { scrollYPercentage: document.body.scrollHeight ? scrollY / document.body.scrollHeight : 0 }), { passive: true }); window.addEventListener('wheel', event => post('did-scroll-wheel', { deltaMode: event.deltaMode, deltaX: event.deltaX, deltaY: event.deltaY, deltaZ: event.deltaZ }), { passive: true }); const keyData = event => ({ key: event.key, keyCode: event.keyCode, code: event.code, shiftKey: event.shiftKey, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, repeat: event.repeat, isTrusted: event.isTrusted }); window.addEventListener('keydown', event => post('did-keydown', keyData(event))); window.addEventListener('keyup', event => post('did-keyup', keyData(event))); - window.addEventListener('dragenter', () => post('drag-start')); - window.addEventListener('dragover', event => post('drag', { shiftKey: event.shiftKey })); + const hasOnlyFiles = event => event.dataTransfer?.items.length && Array.from(event.dataTransfer.items).every(item => item.kind === 'file'); + window.addEventListener('dragenter', event => { if (!event.defaultPrevented && !event.shiftKey && hasOnlyFiles(event)) { post('drag-start'); } }); + window.addEventListener('dragover', event => { event.preventDefault(); if (hasOnlyFiles(event)) { post('drag', { shiftKey: event.shiftKey }); } }); + window.addEventListener('drop', event => event.preventDefault()); window.addEventListener('contextmenu', event => post('did-context-menu', { clientX: event.clientX, clientY: event.clientY, context: {} })); document.addEventListener('click', event => { const anchor = event.target instanceof Element ? event.target.closest('a[href]') : null; @@ -161,6 +169,14 @@ export class ElectronWebviewElement extends WebviewElement { request.dispose(true); } })); + this._register(this._webviewMainService.onDidRequestWebviewPortMapping(async request => { + if (this.useSingleIframe + && request.extensionId.toLowerCase() === this.extension?.id.value.toLowerCase() + && request.webviewId === this.resourceId) { + const redirect = await this.getDirectLocalhostRedirect(request.origin); + await this._webviewMainService.resolveWebviewPortMapping(request.requestId, redirect); + } + })); if (initInfo.options.enableFindWidget) { this._register(this.onDidHtmlChange((newContent) => { @@ -361,6 +377,7 @@ export class ElectronWebviewElement extends WebviewElement { const document = new DOMParser().parseFromString(trustedSource as string, 'text/html'); const policies = document.querySelectorAll('meta[http-equiv="Content-Security-Policy" i]'); if (policies.length !== 1 || !policies[0].getAttribute('content')?.trim()) { + this.handleNoCspFound(); return { html: 'Webview blocked: the experimental loader requires exactly one Content-Security-Policy meta tag.', csp: "default-src 'none'; style-src 'unsafe-inline'", From 5ffd71f20349f61b3288cb6fc6cda5c2ec517f2d Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Sat, 11 Jul 2026 22:16:08 +0000 Subject: [PATCH 04/12] fix --- implementation-plan.md | 10 +- .../webview/common/webviewManagerService.ts | 2 +- .../electron-main/webviewProtocolProvider.ts | 101 ++++++++++++++++-- .../electron-browser/webviewElement.ts | 46 ++++---- 4 files changed, 121 insertions(+), 38 deletions(-) diff --git a/implementation-plan.md b/implementation-plan.md index 9817bfd9338a71..7a47e879a60c78 100644 --- a/implementation-plan.md +++ b/implementation-plan.md @@ -19,16 +19,16 @@ ## Document and API loading - Enable the loader only when the proposal is granted and the UI client is Electron. -- In the workbench renderer, transform dynamic `webview.html`, inject default styles and a static hashed bootstrap, extract its CSP, and register the revision with Electron main before navigation. -- Navigate the single iframe directly to the registered document. Re-register and navigate on HTML changes, reject stale revisions, and queue messages while a new document handshakes. +- In the workbench renderer, transform dynamic `webview.html`, inject default styles and a static hashed bootstrap, extract its CSP, and register the current document with Electron main before navigation. +- Navigate the single iframe directly to the currently registered document at a stable, query-free URL. Re-register and navigate on HTML changes, and queue messages while a new document handshakes. - The bootstrap installs `acquireVsCodeApi`, owns a `MessageChannel`, preserves state and messaging semantics, and handles themes, focus, keyboard, drag/drop, links, and load events. -- Validate the handshake with `event.source`, instance ID, document revision, and a per-navigation nonce rather than `event.origin`. +- Validate the handshake with `event.source`, instance ID, and an injected per-navigation generation value rather than URL parameters or `event.origin`. ## Protocol and resources - In experimental mode, make `asWebviewUri` return resources below `vscode-webview:////_vscode/resource/...`; preserve source paths, queries, and fragments. Keep HTTP(S) and legacy behavior unchanged. - Extend the existing Electron protocol handler to serve registered documents and authorized resources for `GET` and `HEAD` only. -- Keep file reads in the workbench renderer so remote and virtual providers work. Bridge request metadata, streaming chunks, ranges, conditional headers, and cancellation between Electron main and the owning `ElectronWebviewElement`. +- Load ordinary local files beneath the extension's own canonical directory directly in Electron main with `net.fetch`. Keep the renderer bridge for remote and virtual providers, additional resource roots, ranges, conditional requests, and fallback compatibility. - Preserve current status, MIME, ETag, range, media, and port-mapping behavior without Cache Storage. - Decode resource URIs once, reject malformed/traversal inputs, enforce `localResourceRoots`, and use canonical real paths where the provider supports them. @@ -44,6 +44,6 @@ - Leave legacy extensions, browser clients, internal webviews, and notebook renderers unchanged. - Treat origin-bound storage and service workers as unsupported in the opaque-origin experimental mode; persistent state uses the VS Code API. - Add a minimal built-in extension with one command, a panel, stylesheet, image, API message round trip, and persisted visible state. -- Add unit coverage for gating, URL parsing, CSP transformation, registrations and ownership, revisions, cancellation, root containment, symlink escape, ranges, CORS, and headers. +- Add unit coverage for gating, URL parsing, CSP transformation, registrations and ownership, document updates, cancellation, root containment, symlink escape, ranges, CORS, and headers. - Add Electron coverage for the single iframe, readable URL, opaque sandbox, API behavior, resources, remote/virtual providers, port mapping, cross-instance denial, absence of worker/cache activity, and legacy compatibility. - Begin with a feasibility check for opaque custom-protocol documents, response-header CSP, parent messaging, modules, fonts, media, and fetch. Do not reintroduce the second iframe or `allow-same-origin` if a Chromium limitation is found. diff --git a/src/vs/platform/webview/common/webviewManagerService.ts b/src/vs/platform/webview/common/webviewManagerService.ts index 74e9eb02ffe134..6e355598bda5cb 100644 --- a/src/vs/platform/webview/common/webviewManagerService.ts +++ b/src/vs/platform/webview/common/webviewManagerService.ts @@ -57,7 +57,7 @@ export interface WebviewDocumentRegistration { readonly extensionId: string; readonly webviewId: string; readonly windowId: number; - readonly revision: number; + readonly extensionLocation: UriComponents | undefined; readonly html: string; readonly csp: string; readonly roots: readonly UriComponents[]; diff --git a/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts b/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts index 0b05ee6f258322..7ba143b2c7185c 100644 --- a/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts +++ b/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts @@ -3,12 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { protocol } from 'electron'; +import { net, protocol } from 'electron'; import { VSBuffer } from '../../../base/common/buffer.js'; import { IDisposable } from '../../../base/common/lifecycle.js'; import { AppResourcePath, COI, FileAccess, Schemas } from '../../../base/common/network.js'; +import { extUriBiasedIgnorePathCase } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; import { IFileService } from '../../files/common/files.js'; +import { getWebviewContentMimeType } from '../common/mimeTypes.js'; import { WebviewDocumentRegistration, WebviewPortMappingRequest, WebviewResourceRequest, WebviewResourceResponse } from '../common/webviewManagerService.js'; @@ -30,6 +32,7 @@ export class WebviewProtocolProvider implements IDisposable { }>(); private nextRequestId = 1; private readonly pendingPortMappings = new Map void>(); + private readonly localResourceAuthorizations = new Map>(); constructor( private readonly requestResource: (request: WebviewResourceRequest) => void, @@ -46,11 +49,12 @@ export class WebviewProtocolProvider implements IDisposable { dispose(): void { protocol.unhandle(Schemas.vscodeWebview); for (const [requestId, pending] of this.pendingResources) { - pending.resolve(new Response(null, { status: 410 })); + pending.resolve(new Response(null, { status: 499 })); this.cancelResource(requestId); } this.pendingResources.clear(); WebviewProtocolProvider.documents.clear(); + this.localResourceAuthorizations.clear(); for (const resolve of this.pendingPortMappings.values()) { resolve(undefined); } this.pendingPortMappings.clear(); if (WebviewProtocolProvider.instance === this) { WebviewProtocolProvider.instance = undefined; } @@ -60,16 +64,21 @@ export class WebviewProtocolProvider implements IDisposable { if (!/^[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*$/.test(document.extensionId.toLowerCase()) || !document.webviewId || document.webviewId.includes('/')) { throw new Error('Invalid direct webview route'); } - WebviewProtocolProvider.documents.set(this.documentKey(document.extensionId, document.webviewId), document); + const key = this.documentKey(document.extensionId, document.webviewId); + WebviewProtocolProvider.documents.set(key, document); + this.localResourceAuthorizations.delete(key); + void this.getLocalResourceAuthorization(document); } public unregisterWebviewDocument(extensionId: string, webviewId: string): void { - WebviewProtocolProvider.documents.delete(this.documentKey(extensionId, webviewId)); + const key = this.documentKey(extensionId, webviewId); + WebviewProtocolProvider.documents.delete(key); + this.localResourceAuthorizations.delete(key); for (const [requestId, pending] of this.pendingResources) { if (pending.extensionId.toLowerCase() === extensionId.toLowerCase() && pending.webviewId === webviewId) { this.pendingResources.delete(requestId); pending.controller?.error(new Error('Webview disposed')); - pending.resolve(new Response(null, { status: 410 })); + pending.resolve(new Response(null, { status: 499 })); this.cancelResource(requestId); } } @@ -227,10 +236,6 @@ export class WebviewProtocolProvider implements IDisposable { } if (match[2] === 'index.html') { - const revision = Number(new URL(request.url).searchParams.get('revision')); - if (revision !== document.revision) { - return new Response(null, { status: 410 }); - } return new Response(request.method === 'HEAD' ? null : document.html, { headers: { 'Content-Type': 'text/html; charset=utf-8', @@ -247,6 +252,10 @@ export class WebviewProtocolProvider implements IDisposable { if (!resource) { return new Response(null, { status: 403 }); } + const localResponse = await this.tryLoadLocalExtensionResource(request, resource, document); + if (localResponse) { + return localResponse; + } if (this.pendingResources.size >= 128) { return new Response(null, { status: 429 }); } @@ -278,6 +287,80 @@ export class WebviewProtocolProvider implements IDisposable { }); } + private async tryLoadLocalExtensionResource(request: GlobalRequest, resource: URI, document: WebviewDocumentRegistration): Promise { + const extensionLocation = document.extensionLocation ? URI.revive(document.extensionLocation) : undefined; + if (resource.scheme !== Schemas.file + || extensionLocation?.scheme !== Schemas.file + || request.headers.has('range') + || request.headers.has('if-none-match')) { + return undefined; + } + + try { + const [canonicalResource, authorization] = await Promise.all([ + this._fileService.realpath(resource.with({ query: '', fragment: '' })), + this.getLocalResourceAuthorization(document), + ]); + if (!canonicalResource || !authorization || !extUriBiasedIgnorePathCase.isEqualOrParent(canonicalResource, authorization.extension)) { + return undefined; + } + + if (!authorization.roots.some(root => extUriBiasedIgnorePathCase.isEqualOrParent(canonicalResource, root))) { + return undefined; + } + + const response = await net.fetch(canonicalResource.with({ query: resource.query }).toString(true), { + method: request.method, + signal: request.signal, + bypassCustomProtocolHandlers: true, + }); + const headers = new Headers(response.headers); + headers.set('Content-Type', getWebviewContentMimeType(resource)); + headers.set('Access-Control-Allow-Origin', '*'); + headers.set('Cross-Origin-Resource-Policy', 'cross-origin'); + headers.set('X-Content-Type-Options', 'nosniff'); + return new Response(request.method === 'HEAD' ? null : response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + } catch { + // The renderer loader remains the compatibility path for providers and + // file requests that Chromium's file protocol cannot serve. + return undefined; + } + } + + private getLocalResourceAuthorization(document: WebviewDocumentRegistration): Promise<{ readonly extension: URI; readonly roots: readonly URI[] } | undefined> { + const key = this.documentKey(document.extensionId, document.webviewId); + let authorization = this.localResourceAuthorizations.get(key); + if (!authorization) { + authorization = (async () => { + const extensionLocation = document.extensionLocation ? URI.revive(document.extensionLocation) : undefined; + if (extensionLocation?.scheme !== Schemas.file) { + return undefined; + } + const extension = await this._fileService.realpath(extensionLocation); + if (!extension) { + return undefined; + } + const roots: URI[] = []; + for (const rawRoot of document.roots) { + const root = URI.revive(rawRoot); + if (root.scheme === Schemas.file) { + const canonicalRoot = await this._fileService.realpath(root); + if (canonicalRoot) { + roots.push(canonicalRoot); + } + } + } + return { extension, roots }; + })().catch(() => undefined); + this.localResourceAuthorizations.set(key, authorization); + } + return authorization; + } + private parseRange(value: string | null): { start: number; end?: number } | undefined { if (!value) { return undefined; } const match = /^bytes=(\d+)-(\d*)$/.exec(value); diff --git a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts index 9529456609f508..4e12400a8f911e 100644 --- a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts @@ -31,9 +31,9 @@ import { WebviewResourceResponse } from '../browser/resourceLoading.js'; import { WindowIgnoreMenuShortcutsManager } from './windowIgnoreMenuShortcutsManager.js'; const singleIframeBootstrap = String.raw`(() => { - const params = new URL(location.href).searchParams; - const target = params.get('target'); - const parentOrigin = params.get('parentOrigin'); + const bootstrapElement = document.querySelector('meta[name="vscode-webview-bootstrap"]'); + const bootstrap = bootstrapElement?.content ? JSON.parse(decodeURIComponent(bootstrapElement.content)) : {}; + bootstrapElement?.remove(); const channel = new MessageChannel(); let acquired = false; const stateElement = document.querySelector('meta[name="vscode-webview-state"]'); @@ -99,7 +99,7 @@ const singleIframeBootstrap = String.raw`(() => { if (anchor) { event.preventDefault(); post('did-click-link', { uri: anchor.href }); } }); - parent.postMessage({ target, channel: 'webview-ready', data: { revision: Number(params.get('revision')), nonce: params.get('nonce') } }, parentOrigin, [channel.port2]); + parent.postMessage({ target: bootstrap.target, channel: 'webview-ready', data: { generation: bootstrap.generation } }, '*', [channel.port2]); })();`; const singleIframeDefaultStyles = `@layer vscode-default { @@ -131,8 +131,8 @@ export class ElectronWebviewElement extends WebviewElement { private readonly _webviewMainService: IWebviewManagerService; private readonly _iframeDelayer = this._register(new Delayer(200)); private _directTargetWindow: CodeWindow | undefined; - private _directRevision = 0; - private _directNonce: string | undefined; + private _directGeneration = 0; + private _directHandshakeId: string | undefined; private _directContentKey: string | undefined; private _directUpdate: Promise = Promise.resolve(); private readonly _directResourceRequests = new Map(); @@ -304,8 +304,7 @@ export class ElectronWebviewElement extends WebviewElement { return !this.useSingleIframe || (typeof data === 'object' && data !== null - && (data as { revision?: number }).revision === this._directRevision - && (data as { nonce?: string }).nonce === this._directNonce); + && (data as { generation?: string }).generation === this._directHandshakeId); } private async updateDirectDocument(prepareForNavigation: boolean): Promise { @@ -328,23 +327,26 @@ export class ElectronWebviewElement extends WebviewElement { this._directContentKey = contentKey; this._directUpdate = this._directUpdate.then(async () => { - const revision = ++this._directRevision; - const navigationNonce = generateUuid(); - this._directNonce = navigationNonce; - const transformed = await this.transformDirectHtml(this.content.html, !!this.content.options.allowScripts); - if (revision !== this._directRevision) { + const generation = ++this._directGeneration; + const handshakeId = generateUuid(); + this._directHandshakeId = handshakeId; + const transformed = await this.transformDirectHtml(this.content.html, !!this.content.options.allowScripts, { + target: this.id, + generation: handshakeId, + }); + if (generation !== this._directGeneration) { return; } await this._webviewMainService.registerWebviewDocument({ extensionId, webviewId, windowId: this.windowId!, - revision, + extensionLocation: this.extension?.location, html: transformed.html, csp: transformed.csp, roots: this.content.options.localResourceRoots || [], }); - if (revision !== this._directRevision || !this.element) { + if (generation !== this._directGeneration || !this.element) { return; } @@ -360,18 +362,12 @@ export class ElectronWebviewElement extends WebviewElement { this.prepareForDirectNavigation(targetWindow); this.style(); } - const query = new URLSearchParams({ - revision: String(revision), - nonce: navigationNonce, - target: this.id, - parentOrigin: targetWindow.origin, - }); - this.element.src = `${Schemas.vscodeWebview}://${extensionId}/${encodeURIComponent(webviewId)}/index.html?${query}`; + this.element.src = `${Schemas.vscodeWebview}://${extensionId}/${encodeURIComponent(webviewId)}/index.html`; }); await this._directUpdate; } - private async transformDirectHtml(html: string, allowScripts: boolean): Promise<{ html: string; csp: string }> { + private async transformDirectHtml(html: string, allowScripts: boolean, bootstrapData: { readonly target: string; readonly generation: string }): Promise<{ html: string; csp: string }> { const source = html || ''; const trustedSource = singleIframeHtmlPolicy?.createHTML(source) ?? source; const document = new DOMParser().parseFromString(trustedSource as string, 'text/html'); @@ -395,6 +391,10 @@ export class ElectronWebviewElement extends WebviewElement { const script = document.createElement('script'); script.text = (singleIframeHtmlPolicy?.createScript?.(singleIframeBootstrap) ?? singleIframeBootstrap) as string; document.head.prepend(script); + const bootstrap = document.createElement('meta'); + bootstrap.name = 'vscode-webview-bootstrap'; + bootstrap.content = encodeURIComponent(JSON.stringify(bootstrapData)); + document.head.prepend(bootstrap); const defaultStyles = document.createElement('style'); defaultStyles.id = '_defaultStyles'; defaultStyles.textContent = singleIframeDefaultStyles; From e8e533a15d1f89b501c4e276b3131a25687bec03 Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Sun, 12 Jul 2026 11:03:33 +0000 Subject: [PATCH 05/12] Support single iframe loader in webview views --- .../contrib/webviewView/browser/webviewViewPane.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/webviewView/browser/webviewViewPane.ts b/src/vs/workbench/contrib/webviewView/browser/webviewViewPane.ts index acde3e67dfa53b..68bbd4361da365 100644 --- a/src/vs/workbench/contrib/webviewView/browser/webviewViewPane.ts +++ b/src/vs/workbench/contrib/webviewView/browser/webviewViewPane.ts @@ -27,7 +27,7 @@ import { ExtensionKeyedWebviewOriginStore, IOverlayWebview, IWebviewService, Web import { WebviewWindowDragMonitor } from '../../webview/browser/webviewWindowDragMonitor.js'; import { IWebviewViewService, WebviewView } from './webviewViewService.js'; import { IActivityService, NumberBadge } from '../../../services/activity/common/activity.js'; -import { IExtensionService } from '../../../services/extensions/common/extensions.js'; +import { IExtensionService, isProposedApiEnabled } from '../../../services/extensions/common/extensions.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; const storageKeys = { @@ -166,13 +166,20 @@ export class WebviewViewPane extends ViewPane { this._activated = true; const origin = this.extensionId ? WebviewViewPane.getOriginStore(this.storageService).getOrigin(this.id, this.extensionId) : undefined; + const extensionDescription = this.extensionId + ? this.extensionService.extensions.find(extension => ExtensionIdentifier.equals(extension.identifier, this.extensionId)) + : undefined; const webview = this.webviewService.createWebviewOverlay({ origin, providedViewType: this.id, title: this.title, options: { purpose: WebviewContentPurpose.WebviewView }, contentOptions: {}, - extension: this.extensionId ? { id: this.extensionId } : undefined + extension: extensionDescription ? { + id: extensionDescription.identifier, + location: extensionDescription.extensionLocation, + useSingleIframe: isProposedApiEnabled(extensionDescription, 'webviewNoServiceWorker') + } : this.extensionId ? { id: this.extensionId } : undefined }); webview.state = this.viewState[storageKeys.webviewState]; this._webview.value = webview; From 467e3e9e1ee9082afb142baa86f003c2b2bf051c Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Sun, 12 Jul 2026 15:48:51 +0000 Subject: [PATCH 06/12] Fix single iframe webview message metadata --- .../contrib/webview/electron-browser/webviewElement.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts index 4e12400a8f911e..698377cacebb0d 100644 --- a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts @@ -73,7 +73,7 @@ const singleIframeBootstrap = String.raw`(() => { switch (name) { case 'content': state = args.state; document.title = args.title || ''; break; case 'styles': applyStyles(args); break; - case 'message': window.dispatchEvent(new MessageEvent('message', { data: args.message })); break; + case 'message': window.dispatchEvent(new MessageEvent('message', { data: args.message, origin: window.location.origin, source: window, ports: event.ports })); break; case 'focus': window.focus(); break; case 'execCommand': document.execCommand(args); break; case 'initial-scroll-position': window.scrollTo(0, document.body.scrollHeight * args); break; From a1b73ebe57afd0b142a79b12d662b0a19fbb9ac1 Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Fri, 17 Jul 2026 11:56:11 +0000 Subject: [PATCH 07/12] Minimize direct webview resource loading --- .../webview-no-service-worker/extension.js | 67 ----------- .../webview-no-service-worker/media/image.svg | 4 - .../webview-no-service-worker/media/style.css | 13 --- .../webview-no-service-worker/package.json | 36 ------ implementation-plan.md | 49 -------- src/vs/code/electron-main/app.ts | 6 + .../webview/common/webviewManagerService.ts | 2 - .../electron-main/webviewProtocolProvider.ts | 108 ++++-------------- .../api/browser/mainThreadWebviews.ts | 1 - src/vs/workbench/api/common/extHostWebview.ts | 1 - .../api/test/browser/extHostWebview.test.ts | 2 +- .../contrib/webview/browser/overlayWebview.ts | 8 -- .../webview/browser/resourceLoading.ts | 36 ------ .../contrib/webview/browser/webview.ts | 2 - .../contrib/webview/browser/webviewElement.ts | 5 - .../contrib/webview/common/webview.ts | 3 +- .../electron-browser/webviewElement.ts | 44 +++---- .../test/browser/resourceLoading.test.ts | 8 +- 18 files changed, 57 insertions(+), 338 deletions(-) delete mode 100644 extensions/webview-no-service-worker/extension.js delete mode 100644 extensions/webview-no-service-worker/media/image.svg delete mode 100644 extensions/webview-no-service-worker/media/style.css delete mode 100644 extensions/webview-no-service-worker/package.json delete mode 100644 implementation-plan.md diff --git a/extensions/webview-no-service-worker/extension.js b/extensions/webview-no-service-worker/extension.js deleted file mode 100644 index 29436506c0e0e5..00000000000000 --- a/extensions/webview-no-service-worker/extension.js +++ /dev/null @@ -1,67 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -const vscode = require('vscode'); - -/** @param {vscode.ExtensionContext} context */ -function activate(context) { - context.subscriptions.push(vscode.commands.registerCommand('webviewNoServiceWorker.open', () => { - const panel = vscode.window.createWebviewPanel( - 'webviewNoServiceWorker.test', - 'Single-Iframe Webview', - vscode.ViewColumn.Active, - { enableScripts: true, localResourceRoots: [vscode.Uri.joinPath(context.extensionUri, 'media')] } - ); - const style = panel.webview.asWebviewUri(vscode.Uri.joinPath(context.extensionUri, 'media', 'style.css')); - const image = panel.webview.asWebviewUri(vscode.Uri.joinPath(context.extensionUri, 'media', 'image.svg')); - const nonce = 'vscodeSingleIframeTest'; - panel.webview.html = ` - - - - - -

Single-iframe webview

- VS Code test image -

Waiting for VS Code API…

- - - `; - panel.webview.onDidReceiveMessage(message => { - if (message.type === 'ready') { - void panel.webview.postMessage({ type: 'pong' }); - void vscode.window.setStatusBarMessage('Single-iframe webview ready', 3000); - } - }); - })); -} - -exports.activate = activate; diff --git a/extensions/webview-no-service-worker/media/image.svg b/extensions/webview-no-service-worker/media/image.svg deleted file mode 100644 index 6b178f097c1403..00000000000000 --- a/extensions/webview-no-service-worker/media/image.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/extensions/webview-no-service-worker/media/style.css b/extensions/webview-no-service-worker/media/style.css deleted file mode 100644 index 643c4b1c5f2fed..00000000000000 --- a/extensions/webview-no-service-worker/media/style.css +++ /dev/null @@ -1,13 +0,0 @@ -body { - font-family: var(--vscode-font-family); - color: var(--vscode-foreground); - background: var(--vscode-editor-background); - padding: 24px; -} - -button { - color: var(--vscode-button-foreground); - background: var(--vscode-button-background); - border: 0; - padding: 8px 12px; -} diff --git a/extensions/webview-no-service-worker/package.json b/extensions/webview-no-service-worker/package.json deleted file mode 100644 index d2108f123dd216..00000000000000 --- a/extensions/webview-no-service-worker/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "webview-no-service-worker", - "displayName": "Webview Without Service Worker (Experimental)", - "description": "Manual test extension for the experimental single-iframe Electron webview loader.", - "version": "0.0.1", - "publisher": "vscode", - "license": "MIT", - "private": true, - "engines": { - "vscode": "^1.105.0" - }, - "enabledApiProposals": [ - "webviewNoServiceWorker" - ], - "extensionKind": [ - "ui" - ], - "capabilities": { - "untrustedWorkspaces": { - "supported": true - } - }, - "main": "./extension.js", - "activationEvents": [ - "onCommand:webviewNoServiceWorker.open" - ], - "contributes": { - "commands": [ - { - "command": "webviewNoServiceWorker.open", - "title": "Open Single-Iframe Webview", - "category": "Developer" - } - ] - } -} diff --git a/implementation-plan.md b/implementation-plan.md deleted file mode 100644 index 7a47e879a60c78..00000000000000 --- a/implementation-plan.md +++ /dev/null @@ -1,49 +0,0 @@ -# Single-Iframe Electron Webviews Without Service Workers - -## Summary - -- Add an experimental Electron-only loader selected with `"enabledApiProposals": ["webviewNoServiceWorker"]`. -- Use exactly one iframe loading `vscode-webview:////index.html`. -- Use the normalized extension ID as the readable authority and the existing webview handle/iframe ID as the instance ID. These are routing identifiers, not secrets. -- Sandbox the iframe with `allow-scripts` and conditional forms/downloads/pointer-lock permissions, but never `allow-same-origin`. -- Keep the existing service-worker implementation unchanged for non-opted extensions and VS Code for the Web. - -## Identity and security boundaries - -- Register each `/` pair with its owning workbench window and iframe. -- Validate every document and resource request against that registration, its requesting frame, and the instance's current `localResourceRoots`. -- Do not authorize from CORS, `Origin`, or identifier secrecy. Opaque sandbox frames serialize their origin as `null`. -- Reject cross-extension and cross-instance document navigations and resource requests, including instances owned by the same extension. -- Revoke registrations and cancel outstanding work when an iframe or owning window is disposed. - -## Document and API loading - -- Enable the loader only when the proposal is granted and the UI client is Electron. -- In the workbench renderer, transform dynamic `webview.html`, inject default styles and a static hashed bootstrap, extract its CSP, and register the current document with Electron main before navigation. -- Navigate the single iframe directly to the currently registered document at a stable, query-free URL. Re-register and navigate on HTML changes, and queue messages while a new document handshakes. -- The bootstrap installs `acquireVsCodeApi`, owns a `MessageChannel`, preserves state and messaging semantics, and handles themes, focus, keyboard, drag/drop, links, and load events. -- Validate the handshake with `event.source`, instance ID, and an injected per-navigation generation value rather than URL parameters or `event.origin`. - -## Protocol and resources - -- In experimental mode, make `asWebviewUri` return resources below `vscode-webview:////_vscode/resource/...`; preserve source paths, queries, and fragments. Keep HTTP(S) and legacy behavior unchanged. -- Extend the existing Electron protocol handler to serve registered documents and authorized resources for `GET` and `HEAD` only. -- Load ordinary local files beneath the extension's own canonical directory directly in Electron main with `net.fetch`. Keep the renderer bridge for remote and virtual providers, additional resource roots, ranges, conditional requests, and fallback compatibility. -- Preserve current status, MIME, ETag, range, media, and port-mapping behavior without Cache Storage. -- Decode resource URIs once, reject malformed/traversal inputs, enforce `localResourceRoots`, and use canonical real paths where the provider supports them. - -## CSP - -- Require one non-empty extension-authored CSP meta tag, remove it from the document, and return it as a response header. -- Add the exact bootstrap hash. When extension scripts are disabled, add an enforcement policy allowing only the bootstrap. -- Fail closed for missing or ambiguous CSP; do not silently fall back. -- Add `Referrer-Policy: no-referrer`, `X-Content-Type-Options: nosniff`, appropriate CORS/CORP headers, and `Cache-Control: no-store` for HTML. - -## Compatibility and tests - -- Leave legacy extensions, browser clients, internal webviews, and notebook renderers unchanged. -- Treat origin-bound storage and service workers as unsupported in the opaque-origin experimental mode; persistent state uses the VS Code API. -- Add a minimal built-in extension with one command, a panel, stylesheet, image, API message round trip, and persisted visible state. -- Add unit coverage for gating, URL parsing, CSP transformation, registrations and ownership, document updates, cancellation, root containment, symlink escape, ranges, CORS, and headers. -- Add Electron coverage for the single iframe, readable URL, opaque sandbox, API behavior, resources, remote/virtual providers, port mapping, cross-instance denial, absence of worker/cache activity, and legacy compatibility. -- Begin with a feasibility check for opaque custom-protocol documents, response-header CSP, parent messaging, modules, fonts, media, and fetch. Do not reintroduce the second iframe or `allow-same-origin` if a Chromium limitation is found. diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 1addbf4d0ddb7d..184306b2678af5 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -374,6 +374,12 @@ export class CodeApplication extends Disposable { session.defaultSession.webRequest.onBeforeRequest((details, callback) => { const uri = URI.parse(details.url); + if (uri.scheme === Schemas.vscodeWebview && details.frame) { + const redirectURL = WebviewProtocolProvider.getWebviewResourceRedirect(details.frame.url, uri); + if (redirectURL) { + return callback({ redirectURL }); + } + } if ((uri.scheme === Schemas.http || uri.scheme === Schemas.https) && details.frame) { const portMapping = WebviewProtocolProvider.getWebviewPortMapping(details.frame.url, details.url); if (portMapping) { diff --git a/src/vs/platform/webview/common/webviewManagerService.ts b/src/vs/platform/webview/common/webviewManagerService.ts index 6e355598bda5cb..9fb7b437a0d0cc 100644 --- a/src/vs/platform/webview/common/webviewManagerService.ts +++ b/src/vs/platform/webview/common/webviewManagerService.ts @@ -57,10 +57,8 @@ export interface WebviewDocumentRegistration { readonly extensionId: string; readonly webviewId: string; readonly windowId: number; - readonly extensionLocation: UriComponents | undefined; readonly html: string; readonly csp: string; - readonly roots: readonly UriComponents[]; } export interface WebviewResourceRequest { diff --git a/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts b/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts index 7ba143b2c7185c..72a1a85f4c8676 100644 --- a/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts +++ b/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts @@ -3,14 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { net, protocol } from 'electron'; +import { protocol } from 'electron'; import { VSBuffer } from '../../../base/common/buffer.js'; import { IDisposable } from '../../../base/common/lifecycle.js'; import { AppResourcePath, COI, FileAccess, Schemas } from '../../../base/common/network.js'; -import { extUriBiasedIgnorePathCase } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; import { IFileService } from '../../files/common/files.js'; -import { getWebviewContentMimeType } from '../common/mimeTypes.js'; import { WebviewDocumentRegistration, WebviewPortMappingRequest, WebviewResourceRequest, WebviewResourceResponse } from '../common/webviewManagerService.js'; @@ -32,7 +30,6 @@ export class WebviewProtocolProvider implements IDisposable { }>(); private nextRequestId = 1; private readonly pendingPortMappings = new Map void>(); - private readonly localResourceAuthorizations = new Map>(); constructor( private readonly requestResource: (request: WebviewResourceRequest) => void, @@ -54,7 +51,6 @@ export class WebviewProtocolProvider implements IDisposable { } this.pendingResources.clear(); WebviewProtocolProvider.documents.clear(); - this.localResourceAuthorizations.clear(); for (const resolve of this.pendingPortMappings.values()) { resolve(undefined); } this.pendingPortMappings.clear(); if (WebviewProtocolProvider.instance === this) { WebviewProtocolProvider.instance = undefined; } @@ -66,14 +62,11 @@ export class WebviewProtocolProvider implements IDisposable { } const key = this.documentKey(document.extensionId, document.webviewId); WebviewProtocolProvider.documents.set(key, document); - this.localResourceAuthorizations.delete(key); - void this.getLocalResourceAuthorization(document); } public unregisterWebviewDocument(extensionId: string, webviewId: string): void { const key = this.documentKey(extensionId, webviewId); WebviewProtocolProvider.documents.delete(key); - this.localResourceAuthorizations.delete(key); for (const [requestId, pending] of this.pendingResources) { if (pending.extensionId.toLowerCase() === extensionId.toLowerCase() && pending.webviewId === webviewId) { this.pendingResources.delete(requestId); @@ -148,6 +141,27 @@ export class WebviewProtocolProvider implements IDisposable { return this.documents.get(`${url.authority.toLowerCase()}\0${decodeURIComponent(match[1])}`); } + public static getWebviewResourceRedirect(frameUrl: string, resource: URI): string | undefined { + if (resource.scheme !== Schemas.vscodeWebview || !resource.path.startsWith('/_vscode/resource/')) { + return undefined; + } + let frame: URI; + try { + frame = URI.parse(frameUrl); + } catch { + return undefined; + } + const document = this.getWebviewDocument(frame); + if (!document || resource.authority.toLowerCase() !== document.extensionId.toLowerCase()) { + return undefined; + } + const route = `${Schemas.vscodeWebview}://${document.extensionId.toLowerCase()}/${encodeURIComponent(document.webviewId)}/`; + if (!frameUrl.startsWith(route)) { + return undefined; + } + return resource.with({ path: `/${document.webviewId}${resource.path}` }).toString(); + } + public static getWebviewPortMapping(frameUrl: string, targetUrl: string): Promise | undefined { const instance = this.instance; if (!instance) { return undefined; } @@ -252,10 +266,6 @@ export class WebviewProtocolProvider implements IDisposable { if (!resource) { return new Response(null, { status: 403 }); } - const localResponse = await this.tryLoadLocalExtensionResource(request, resource, document); - if (localResponse) { - return localResponse; - } if (this.pendingResources.size >= 128) { return new Response(null, { status: 429 }); } @@ -287,80 +297,6 @@ export class WebviewProtocolProvider implements IDisposable { }); } - private async tryLoadLocalExtensionResource(request: GlobalRequest, resource: URI, document: WebviewDocumentRegistration): Promise { - const extensionLocation = document.extensionLocation ? URI.revive(document.extensionLocation) : undefined; - if (resource.scheme !== Schemas.file - || extensionLocation?.scheme !== Schemas.file - || request.headers.has('range') - || request.headers.has('if-none-match')) { - return undefined; - } - - try { - const [canonicalResource, authorization] = await Promise.all([ - this._fileService.realpath(resource.with({ query: '', fragment: '' })), - this.getLocalResourceAuthorization(document), - ]); - if (!canonicalResource || !authorization || !extUriBiasedIgnorePathCase.isEqualOrParent(canonicalResource, authorization.extension)) { - return undefined; - } - - if (!authorization.roots.some(root => extUriBiasedIgnorePathCase.isEqualOrParent(canonicalResource, root))) { - return undefined; - } - - const response = await net.fetch(canonicalResource.with({ query: resource.query }).toString(true), { - method: request.method, - signal: request.signal, - bypassCustomProtocolHandlers: true, - }); - const headers = new Headers(response.headers); - headers.set('Content-Type', getWebviewContentMimeType(resource)); - headers.set('Access-Control-Allow-Origin', '*'); - headers.set('Cross-Origin-Resource-Policy', 'cross-origin'); - headers.set('X-Content-Type-Options', 'nosniff'); - return new Response(request.method === 'HEAD' ? null : response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); - } catch { - // The renderer loader remains the compatibility path for providers and - // file requests that Chromium's file protocol cannot serve. - return undefined; - } - } - - private getLocalResourceAuthorization(document: WebviewDocumentRegistration): Promise<{ readonly extension: URI; readonly roots: readonly URI[] } | undefined> { - const key = this.documentKey(document.extensionId, document.webviewId); - let authorization = this.localResourceAuthorizations.get(key); - if (!authorization) { - authorization = (async () => { - const extensionLocation = document.extensionLocation ? URI.revive(document.extensionLocation) : undefined; - if (extensionLocation?.scheme !== Schemas.file) { - return undefined; - } - const extension = await this._fileService.realpath(extensionLocation); - if (!extension) { - return undefined; - } - const roots: URI[] = []; - for (const rawRoot of document.roots) { - const root = URI.revive(rawRoot); - if (root.scheme === Schemas.file) { - const canonicalRoot = await this._fileService.realpath(root); - if (canonicalRoot) { - roots.push(canonicalRoot); - } - } - } - return { extension, roots }; - })().catch(() => undefined); - this.localResourceAuthorizations.set(key, authorization); - } - return authorization; - } - private parseRange(value: string | null): { start: number; end?: number } | undefined { if (!value) { return undefined; } const match = /^bytes=(\d+)-(\d*)$/.exec(value); diff --git a/src/vs/workbench/api/browser/mainThreadWebviews.ts b/src/vs/workbench/api/browser/mainThreadWebviews.ts index ae6c1c5451435b..3f4145fc1d8ddf 100644 --- a/src/vs/workbench/api/browser/mainThreadWebviews.ts +++ b/src/vs/workbench/api/browser/mainThreadWebviews.ts @@ -48,7 +48,6 @@ export class MainThreadWebviews extends Disposable implements extHostProtocol.Ma throw new Error('Webview already registered'); } - webview.resourceId = handle; this._webviews.set(handle, webview); this.hookupWebviewEventDelegate(handle, webview, options); } diff --git a/src/vs/workbench/api/common/extHostWebview.ts b/src/vs/workbench/api/common/extHostWebview.ts index 5734106fc2115a..ff25ea035820a5 100644 --- a/src/vs/workbench/api/common/extHostWebview.ts +++ b/src/vs/workbench/api/common/extHostWebview.ts @@ -82,7 +82,6 @@ export class ExtHostWebview implements vscode.Webview { this.#hasCalledAsWebviewUri = true; return asWebviewUri(resource, this.#remoteInfo, this.#useSingleIframe ? { extensionId: this.#extension.identifier.value, - webviewId: this.#handle, } : undefined); } diff --git a/src/vs/workbench/api/test/browser/extHostWebview.test.ts b/src/vs/workbench/api/test/browser/extHostWebview.test.ts index 9719d24dcbc6ac..cba3c64e654eb0 100644 --- a/src/vs/workbench/api/test/browser/extHostWebview.test.ts +++ b/src/vs/workbench/api/test/browser/extHostWebview.test.ts @@ -210,7 +210,7 @@ suite('ExtHostWebview', () => { assert.strictEqual(desktopWebview.cspSource, 'vscode-webview://publisher.extension'); assert.strictEqual( desktopWebview.asWebviewUri(URI.file('/extension/media/icon.svg')).toString(), - 'vscode-webview://publisher.extension/readable-instance/_vscode/resource/file%2B/extension/media/icon.svg', + 'vscode-webview://publisher.extension/_vscode/resource/file%2B/extension/media/icon.svg', ); const webWebviews = disposables.add(new ExtHostWebviews(rpcProtocol!, { authority: undefined, isRemote: false }, UIKind.Web, undefined, new NullLogService(), NullApiDeprecationService)); diff --git a/src/vs/workbench/contrib/webview/browser/overlayWebview.ts b/src/vs/workbench/contrib/webview/browser/overlayWebview.ts index 2c96904032531b..fb321a9448f254 100644 --- a/src/vs/workbench/contrib/webview/browser/overlayWebview.ts +++ b/src/vs/workbench/contrib/webview/browser/overlayWebview.ts @@ -36,8 +36,6 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { private _title: string | undefined; private _initialScrollProgress: number = 0; private _state: string | undefined = undefined; - private _resourceId: string | undefined; - private _extension: WebviewExtensionDescription | undefined; private _contentOptions: WebviewContentOptions; private _options: WebviewOptions; @@ -214,7 +212,6 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { extension: this.extension, }); this._webview.value = webview; - webview.resourceId = this._resourceId; webview.state = this._state; if (this._scopedContextKeyService.value) { @@ -403,9 +400,4 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { this._webview.value?.setContextKeyService(contextKeyService); } - public get resourceId(): string | undefined { return this._resourceId; } - public set resourceId(value: string | undefined) { - this._resourceId = value; - this._withWebview(webview => webview.resourceId = value); - } } diff --git a/src/vs/workbench/contrib/webview/browser/resourceLoading.ts b/src/vs/workbench/contrib/webview/browser/resourceLoading.ts index 845e463c3f252d..93aee448b07ee8 100644 --- a/src/vs/workbench/contrib/webview/browser/resourceLoading.ts +++ b/src/vs/workbench/contrib/webview/browser/resourceLoading.ts @@ -67,34 +67,6 @@ export async function loadLocalResource( return WebviewResourceResponse.AccessDenied; } - // File providers that expose real paths let us close the gap between lexical URI - // containment and the path that is actually read (for example a symlink from an - // extension's media directory to /etc/passwd). Providers without realpath retain - // the existing URI-identity containment check above. - const requestWithoutQuery = requestUri.with({ query: '', fragment: '' }); - const canonicalResource = await realpath(fileService, requestWithoutQuery); - if (canonicalResource) { - let checkedCanonicalRoot = false; - let containedByCanonicalRoot = false; - for (const root of options.roots) { - if (!containsResource(root, requestWithoutQuery, uriIdentityService)) { - continue; - } - const canonicalRoot = await realpath(fileService, root); - if (canonicalRoot) { - checkedCanonicalRoot = true; - if (uriIdentityService.extUri.isEqualOrParent(canonicalResource, canonicalRoot, true)) { - containedByCanonicalRoot = true; - break; - } - } - } - if (checkedCanonicalRoot && !containedByCanonicalRoot) { - logService.warn(`Webview.loadLocalResource - access denied after realpath check. requestUri=${requestUri}, canonicalResource=${canonicalResource}`); - return WebviewResourceResponse.AccessDenied; - } - } - const mime = getWebviewContentMimeType(requestUri); // Use the original path for the mime try { @@ -128,14 +100,6 @@ export async function loadLocalResource( } } -async function realpath(fileService: IFileService, resource: URI): Promise { - try { - return await fileService.realpath(resource); - } catch { - return undefined; - } -} - export function getResourceToLoad( requestUri: URI, roots: ReadonlyArray, diff --git a/src/vs/workbench/contrib/webview/browser/webview.ts b/src/vs/workbench/contrib/webview/browser/webview.ts index 27c70871bc32a5..0b32d87dfa97de 100644 --- a/src/vs/workbench/contrib/webview/browser/webview.ts +++ b/src/vs/workbench/contrib/webview/browser/webview.ts @@ -192,8 +192,6 @@ export interface WebviewMessageReceivedEvent { } export interface IWebview extends IDisposable { - /** Internal resource route identifier assigned by the extension host bridge. */ - resourceId?: string; /** * The original view type of the webview. diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index 28bc0700104866..a6ec9ec34f7f82 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -77,9 +77,6 @@ const webviewIdContext = 'webviewId'; export class WebviewElement extends Disposable implements IWebviewElement, WebviewFindDelegate { protected readonly id = generateUuid(); - private _resourceId: string | undefined; - public get resourceId(): string | undefined { return this._resourceId; } - public set resourceId(value: string | undefined) { this._resourceId = value; this.onWebviewRouteChanged(); } /** * The provided identifier of this webview. @@ -694,12 +691,10 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi ...this._content, options: { ...this._content.options, localResourceRoots: resources } }; - this.onContentDidChange(); } public set state(state: string | undefined) { this._content = { ...this._content, state }; - this.onContentDidChange(); } public set initialScrollProgress(value: number) { diff --git a/src/vs/workbench/contrib/webview/common/webview.ts b/src/vs/workbench/contrib/webview/common/webview.ts index b3f163a74c060a..7dc99af025fc9f 100644 --- a/src/vs/workbench/contrib/webview/common/webview.ts +++ b/src/vs/workbench/contrib/webview/common/webview.ts @@ -26,7 +26,6 @@ export const webviewGenericCspSource = `'self' https://*.${webviewResourceBaseHo export interface ElectronWebviewResourceRoute { readonly extensionId: string; - readonly webviewId: string; } const webviewExtensionIdPattern = /^[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*$/; @@ -70,7 +69,7 @@ export function asWebviewUri(resource: URI, remoteInfo?: WebviewRemoteInfo, elec return URI.from({ scheme: Schemas.vscodeWebview, authority: extensionId, - path: `/${electronRoute.webviewId}/_vscode/resource/${resource.scheme}+${encodeAuthority(resource.authority)}${resource.path}`, + path: `/_vscode/resource/${resource.scheme}+${encodeAuthority(resource.authority)}${resource.path}`, fragment: resource.fragment, query: resource.query, }); diff --git a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts index 698377cacebb0d..c2e5ce8b4d9556 100644 --- a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts @@ -11,7 +11,9 @@ import { generateUuid } from '../../../../base/common/uuid.js'; import { CodeWindow } from '../../../../base/browser/window.js'; import { createTrustedTypesPolicy } from '../../../../base/browser/trustedTypes.js'; import { Schemas } from '../../../../base/common/network.js'; +import { escape } from '../../../../base/common/strings.js'; import { ProxyChannel } from '../../../../base/parts/ipc/common/ipc.js'; +import { localize } from '../../../../nls.js'; import { IAccessibilityService } from '../../../../platform/accessibility/common/accessibility.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; @@ -172,7 +174,7 @@ export class ElectronWebviewElement extends WebviewElement { this._register(this._webviewMainService.onDidRequestWebviewPortMapping(async request => { if (this.useSingleIframe && request.extensionId.toLowerCase() === this.extension?.id.value.toLowerCase() - && request.webviewId === this.resourceId) { + && request.webviewId === this.id) { const redirect = await this.getDirectLocalhostRedirect(request.origin); await this._webviewMainService.resolveWebviewPortMapping(request.requestId, redirect); } @@ -196,8 +198,8 @@ export class ElectronWebviewElement extends WebviewElement { // Make sure keyboard handler knows it closed (#71800) this._webviewKeyboardHandler.didBlur(); - if (this.extension?.useSingleIframe && this.resourceId) { - void this._webviewMainService.unregisterWebviewDocument(this.extension.id.value, this.resourceId); + if (this.extension?.useSingleIframe) { + void this._webviewMainService.unregisterWebviewDocument(this.extension.id.value, this.id); } for (const request of this._directResourceRequests.values()) { request.dispose(true); @@ -209,7 +211,7 @@ export class ElectronWebviewElement extends WebviewElement { private async handleDirectResourceRequest(request: WebviewResourceRequest): Promise { if (!this.useSingleIframe || request.extensionId.toLowerCase() !== this.extension?.id.value.toLowerCase() - || request.webviewId !== this.resourceId) { + || request.webviewId !== this.id) { return; } const cts = new CancellationTokenSource(); @@ -309,7 +311,7 @@ export class ElectronWebviewElement extends WebviewElement { private async updateDirectDocument(prepareForNavigation: boolean): Promise { const extensionId = this.extension?.id.value.toLowerCase(); - const webviewId = this.resourceId; + const webviewId = this.id; const targetWindow = this._directTargetWindow; if (!extensionId || !webviewId || !targetWindow || typeof this.windowId !== 'number' || !this.element) { return; @@ -319,7 +321,6 @@ export class ElectronWebviewElement extends WebviewElement { html: this.content.html, allowScripts: this.content.options.allowScripts, allowForms: this.content.options.allowForms, - roots: this.content.options.localResourceRoots?.map(root => root.toString()), }); if (contentKey === this._directContentKey) { return; @@ -341,10 +342,8 @@ export class ElectronWebviewElement extends WebviewElement { extensionId, webviewId, windowId: this.windowId!, - extensionLocation: this.extension?.location, html: transformed.html, csp: transformed.csp, - roots: this.content.options.localResourceRoots || [], }); if (generation !== this._directGeneration || !this.element) { return; @@ -370,13 +369,14 @@ export class ElectronWebviewElement extends WebviewElement { private async transformDirectHtml(html: string, allowScripts: boolean, bootstrapData: { readonly target: string; readonly generation: string }): Promise<{ html: string; csp: string }> { const source = html || ''; const trustedSource = singleIframeHtmlPolicy?.createHTML(source) ?? source; - const document = new DOMParser().parseFromString(trustedSource as string, 'text/html'); - const policies = document.querySelectorAll('meta[http-equiv="Content-Security-Policy" i]'); + const parsedDocument = new DOMParser().parseFromString(trustedSource as string, 'text/html'); + const policies = Array.from(parsedDocument.head.children) + .filter(element => element.tagName === 'META' && element.getAttribute('http-equiv')?.toLowerCase() === 'content-security-policy'); if (policies.length !== 1 || !policies[0].getAttribute('content')?.trim()) { this.handleNoCspFound(); return { - html: 'Webview blocked: the experimental loader requires exactly one Content-Security-Policy meta tag.', - csp: "default-src 'none'; style-src 'unsafe-inline'", + html: `${escape(localize('webviewBlockedMissingCsp', "Webview blocked: the experimental loader requires exactly one Content-Security-Policy meta tag."))}`, + csp: `default-src 'none'; style-src 'unsafe-inline'`, }; } let csp = policies[0].getAttribute('content')!.trim(); @@ -388,23 +388,23 @@ export class ElectronWebviewElement extends WebviewElement { if (!allowScripts) { csp += `, script-src ${hash}; script-src-attr 'none'`; } - const script = document.createElement('script'); + const script = parsedDocument.createElement('script'); script.text = (singleIframeHtmlPolicy?.createScript?.(singleIframeBootstrap) ?? singleIframeBootstrap) as string; - document.head.prepend(script); - const bootstrap = document.createElement('meta'); + parsedDocument.head.prepend(script); + const bootstrap = parsedDocument.createElement('meta'); bootstrap.name = 'vscode-webview-bootstrap'; bootstrap.content = encodeURIComponent(JSON.stringify(bootstrapData)); - document.head.prepend(bootstrap); - const defaultStyles = document.createElement('style'); + parsedDocument.head.prepend(bootstrap); + const defaultStyles = parsedDocument.createElement('style'); defaultStyles.id = '_defaultStyles'; defaultStyles.textContent = singleIframeDefaultStyles; - document.head.prepend(defaultStyles); - const state = document.createElement('meta'); + parsedDocument.head.prepend(defaultStyles); + const state = parsedDocument.createElement('meta'); state.name = 'vscode-webview-state'; state.content = this.content.state ? encodeURIComponent(this.content.state) : ''; - document.head.prepend(state); - document.title = this.content.title || ''; - return { html: `\n${document.documentElement.outerHTML}`, csp }; + parsedDocument.head.prepend(state); + parsedDocument.title = this.content.title || ''; + return { html: `\n${parsedDocument.documentElement.outerHTML}`, csp }; } private async contentHash(value: string): Promise { diff --git a/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts b/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts index 5e064bf5ffdc8c..1cadd2cd7d5e0a 100644 --- a/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts +++ b/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts @@ -245,6 +245,8 @@ suite('Webview Resource Loading - getResourceToLoad', () => { }); suite('Webview Resource Loading - direct Electron routes', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + test('normalizes and validates extension identifiers', () => { assert.strictEqual(normalizeWebviewExtensionId('Publisher.Extension-Name'), 'publisher.extension-name'); assert.strictEqual(normalizeWebviewExtensionId('missingPublisher'), undefined); @@ -255,17 +257,17 @@ suite('Webview Resource Loading - direct Electron routes', () => { const result = asWebviewUri( URI.from({ scheme: 'test-provider', authority: 'remote+host', path: '/目录/a file.css', query: 'v=1', fragment: 'rule' }), undefined, - { extensionId: 'Publisher.Extension', webviewId: 'instance id' }, + { extensionId: 'Publisher.Extension' }, ); assert.strictEqual(result.scheme, Schemas.vscodeWebview); assert.strictEqual(result.authority, 'publisher.extension'); - assert.strictEqual(result.path, '/instance id/_vscode/resource/test-provider+remote-002bhost/目录/a file.css'); + assert.strictEqual(result.path, '/_vscode/resource/test-provider+remote-002bhost/目录/a file.css'); assert.strictEqual(result.query, 'v=1'); assert.strictEqual(result.fragment, 'rule'); }); test('leaves network resources unchanged', () => { const resource = URI.parse('https://example.com/image.png?q=1#fragment'); - assert.strictEqual(asWebviewUri(resource, undefined, { extensionId: 'publisher.extension', webviewId: 'instance' }), resource); + assert.strictEqual(asWebviewUri(resource, undefined, { extensionId: 'publisher.extension' }), resource); }); }); From 4815a0f7fbfffb536c393ad2a60832538ba026c9 Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Fri, 17 Jul 2026 12:14:57 +0000 Subject: [PATCH 08/12] Keep stable direct webview resource routes --- src/vs/code/electron-main/app.ts | 6 ------ .../electron-main/webviewProtocolProvider.ts | 21 ------------------- .../api/browser/mainThreadWebviews.ts | 1 + src/vs/workbench/api/common/extHostWebview.ts | 1 + .../api/test/browser/extHostWebview.test.ts | 2 +- .../contrib/webview/browser/overlayWebview.ts | 8 +++++++ .../contrib/webview/browser/webview.ts | 2 ++ .../contrib/webview/browser/webviewElement.ts | 3 +++ .../contrib/webview/common/webview.ts | 3 ++- .../electron-browser/webviewElement.ts | 10 ++++----- .../test/browser/resourceLoading.test.ts | 6 +++--- 11 files changed, 26 insertions(+), 37 deletions(-) diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 184306b2678af5..1addbf4d0ddb7d 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -374,12 +374,6 @@ export class CodeApplication extends Disposable { session.defaultSession.webRequest.onBeforeRequest((details, callback) => { const uri = URI.parse(details.url); - if (uri.scheme === Schemas.vscodeWebview && details.frame) { - const redirectURL = WebviewProtocolProvider.getWebviewResourceRedirect(details.frame.url, uri); - if (redirectURL) { - return callback({ redirectURL }); - } - } if ((uri.scheme === Schemas.http || uri.scheme === Schemas.https) && details.frame) { const portMapping = WebviewProtocolProvider.getWebviewPortMapping(details.frame.url, details.url); if (portMapping) { diff --git a/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts b/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts index 72a1a85f4c8676..4eb50fda61e675 100644 --- a/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts +++ b/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts @@ -141,27 +141,6 @@ export class WebviewProtocolProvider implements IDisposable { return this.documents.get(`${url.authority.toLowerCase()}\0${decodeURIComponent(match[1])}`); } - public static getWebviewResourceRedirect(frameUrl: string, resource: URI): string | undefined { - if (resource.scheme !== Schemas.vscodeWebview || !resource.path.startsWith('/_vscode/resource/')) { - return undefined; - } - let frame: URI; - try { - frame = URI.parse(frameUrl); - } catch { - return undefined; - } - const document = this.getWebviewDocument(frame); - if (!document || resource.authority.toLowerCase() !== document.extensionId.toLowerCase()) { - return undefined; - } - const route = `${Schemas.vscodeWebview}://${document.extensionId.toLowerCase()}/${encodeURIComponent(document.webviewId)}/`; - if (!frameUrl.startsWith(route)) { - return undefined; - } - return resource.with({ path: `/${document.webviewId}${resource.path}` }).toString(); - } - public static getWebviewPortMapping(frameUrl: string, targetUrl: string): Promise | undefined { const instance = this.instance; if (!instance) { return undefined; } diff --git a/src/vs/workbench/api/browser/mainThreadWebviews.ts b/src/vs/workbench/api/browser/mainThreadWebviews.ts index 3f4145fc1d8ddf..ae6c1c5451435b 100644 --- a/src/vs/workbench/api/browser/mainThreadWebviews.ts +++ b/src/vs/workbench/api/browser/mainThreadWebviews.ts @@ -48,6 +48,7 @@ export class MainThreadWebviews extends Disposable implements extHostProtocol.Ma throw new Error('Webview already registered'); } + webview.resourceId = handle; this._webviews.set(handle, webview); this.hookupWebviewEventDelegate(handle, webview, options); } diff --git a/src/vs/workbench/api/common/extHostWebview.ts b/src/vs/workbench/api/common/extHostWebview.ts index ff25ea035820a5..5734106fc2115a 100644 --- a/src/vs/workbench/api/common/extHostWebview.ts +++ b/src/vs/workbench/api/common/extHostWebview.ts @@ -82,6 +82,7 @@ export class ExtHostWebview implements vscode.Webview { this.#hasCalledAsWebviewUri = true; return asWebviewUri(resource, this.#remoteInfo, this.#useSingleIframe ? { extensionId: this.#extension.identifier.value, + webviewId: this.#handle, } : undefined); } diff --git a/src/vs/workbench/api/test/browser/extHostWebview.test.ts b/src/vs/workbench/api/test/browser/extHostWebview.test.ts index cba3c64e654eb0..9719d24dcbc6ac 100644 --- a/src/vs/workbench/api/test/browser/extHostWebview.test.ts +++ b/src/vs/workbench/api/test/browser/extHostWebview.test.ts @@ -210,7 +210,7 @@ suite('ExtHostWebview', () => { assert.strictEqual(desktopWebview.cspSource, 'vscode-webview://publisher.extension'); assert.strictEqual( desktopWebview.asWebviewUri(URI.file('/extension/media/icon.svg')).toString(), - 'vscode-webview://publisher.extension/_vscode/resource/file%2B/extension/media/icon.svg', + 'vscode-webview://publisher.extension/readable-instance/_vscode/resource/file%2B/extension/media/icon.svg', ); const webWebviews = disposables.add(new ExtHostWebviews(rpcProtocol!, { authority: undefined, isRemote: false }, UIKind.Web, undefined, new NullLogService(), NullApiDeprecationService)); diff --git a/src/vs/workbench/contrib/webview/browser/overlayWebview.ts b/src/vs/workbench/contrib/webview/browser/overlayWebview.ts index fb321a9448f254..2c96904032531b 100644 --- a/src/vs/workbench/contrib/webview/browser/overlayWebview.ts +++ b/src/vs/workbench/contrib/webview/browser/overlayWebview.ts @@ -36,6 +36,8 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { private _title: string | undefined; private _initialScrollProgress: number = 0; private _state: string | undefined = undefined; + private _resourceId: string | undefined; + private _extension: WebviewExtensionDescription | undefined; private _contentOptions: WebviewContentOptions; private _options: WebviewOptions; @@ -212,6 +214,7 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { extension: this.extension, }); this._webview.value = webview; + webview.resourceId = this._resourceId; webview.state = this._state; if (this._scopedContextKeyService.value) { @@ -400,4 +403,9 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { this._webview.value?.setContextKeyService(contextKeyService); } + public get resourceId(): string | undefined { return this._resourceId; } + public set resourceId(value: string | undefined) { + this._resourceId = value; + this._withWebview(webview => webview.resourceId = value); + } } diff --git a/src/vs/workbench/contrib/webview/browser/webview.ts b/src/vs/workbench/contrib/webview/browser/webview.ts index 0b32d87dfa97de..27c70871bc32a5 100644 --- a/src/vs/workbench/contrib/webview/browser/webview.ts +++ b/src/vs/workbench/contrib/webview/browser/webview.ts @@ -192,6 +192,8 @@ export interface WebviewMessageReceivedEvent { } export interface IWebview extends IDisposable { + /** Internal resource route identifier assigned by the extension host bridge. */ + resourceId?: string; /** * The original view type of the webview. diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index a6ec9ec34f7f82..80d6c228a83dd3 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -77,6 +77,9 @@ const webviewIdContext = 'webviewId'; export class WebviewElement extends Disposable implements IWebviewElement, WebviewFindDelegate { protected readonly id = generateUuid(); + private _resourceId: string | undefined; + public get resourceId(): string | undefined { return this._resourceId; } + public set resourceId(value: string | undefined) { this._resourceId = value; this.onWebviewRouteChanged(); } /** * The provided identifier of this webview. diff --git a/src/vs/workbench/contrib/webview/common/webview.ts b/src/vs/workbench/contrib/webview/common/webview.ts index 7dc99af025fc9f..b3f163a74c060a 100644 --- a/src/vs/workbench/contrib/webview/common/webview.ts +++ b/src/vs/workbench/contrib/webview/common/webview.ts @@ -26,6 +26,7 @@ export const webviewGenericCspSource = `'self' https://*.${webviewResourceBaseHo export interface ElectronWebviewResourceRoute { readonly extensionId: string; + readonly webviewId: string; } const webviewExtensionIdPattern = /^[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*$/; @@ -69,7 +70,7 @@ export function asWebviewUri(resource: URI, remoteInfo?: WebviewRemoteInfo, elec return URI.from({ scheme: Schemas.vscodeWebview, authority: extensionId, - path: `/_vscode/resource/${resource.scheme}+${encodeAuthority(resource.authority)}${resource.path}`, + path: `/${electronRoute.webviewId}/_vscode/resource/${resource.scheme}+${encodeAuthority(resource.authority)}${resource.path}`, fragment: resource.fragment, query: resource.query, }); diff --git a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts index c2e5ce8b4d9556..0d3e4b27fa6e4a 100644 --- a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts @@ -174,7 +174,7 @@ export class ElectronWebviewElement extends WebviewElement { this._register(this._webviewMainService.onDidRequestWebviewPortMapping(async request => { if (this.useSingleIframe && request.extensionId.toLowerCase() === this.extension?.id.value.toLowerCase() - && request.webviewId === this.id) { + && request.webviewId === this.resourceId) { const redirect = await this.getDirectLocalhostRedirect(request.origin); await this._webviewMainService.resolveWebviewPortMapping(request.requestId, redirect); } @@ -198,8 +198,8 @@ export class ElectronWebviewElement extends WebviewElement { // Make sure keyboard handler knows it closed (#71800) this._webviewKeyboardHandler.didBlur(); - if (this.extension?.useSingleIframe) { - void this._webviewMainService.unregisterWebviewDocument(this.extension.id.value, this.id); + if (this.extension?.useSingleIframe && this.resourceId) { + void this._webviewMainService.unregisterWebviewDocument(this.extension.id.value, this.resourceId); } for (const request of this._directResourceRequests.values()) { request.dispose(true); @@ -211,7 +211,7 @@ export class ElectronWebviewElement extends WebviewElement { private async handleDirectResourceRequest(request: WebviewResourceRequest): Promise { if (!this.useSingleIframe || request.extensionId.toLowerCase() !== this.extension?.id.value.toLowerCase() - || request.webviewId !== this.id) { + || request.webviewId !== this.resourceId) { return; } const cts = new CancellationTokenSource(); @@ -311,7 +311,7 @@ export class ElectronWebviewElement extends WebviewElement { private async updateDirectDocument(prepareForNavigation: boolean): Promise { const extensionId = this.extension?.id.value.toLowerCase(); - const webviewId = this.id; + const webviewId = this.resourceId; const targetWindow = this._directTargetWindow; if (!extensionId || !webviewId || !targetWindow || typeof this.windowId !== 'number' || !this.element) { return; diff --git a/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts b/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts index 1cadd2cd7d5e0a..1e55be9211d2af 100644 --- a/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts +++ b/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts @@ -257,17 +257,17 @@ suite('Webview Resource Loading - direct Electron routes', () => { const result = asWebviewUri( URI.from({ scheme: 'test-provider', authority: 'remote+host', path: '/目录/a file.css', query: 'v=1', fragment: 'rule' }), undefined, - { extensionId: 'Publisher.Extension' }, + { extensionId: 'Publisher.Extension', webviewId: 'instance id' }, ); assert.strictEqual(result.scheme, Schemas.vscodeWebview); assert.strictEqual(result.authority, 'publisher.extension'); - assert.strictEqual(result.path, '/_vscode/resource/test-provider+remote-002bhost/目录/a file.css'); + assert.strictEqual(result.path, '/instance id/_vscode/resource/test-provider+remote-002bhost/目录/a file.css'); assert.strictEqual(result.query, 'v=1'); assert.strictEqual(result.fragment, 'rule'); }); test('leaves network resources unchanged', () => { const resource = URI.parse('https://example.com/image.png?q=1#fragment'); - assert.strictEqual(asWebviewUri(resource, undefined, { extensionId: 'publisher.extension' }), resource); + assert.strictEqual(asWebviewUri(resource, undefined, { extensionId: 'publisher.extension', webviewId: 'instance' }), resource); }); }); From d4b1e98a2afcad8b673038795ab8b0a34cfc78c2 Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Fri, 17 Jul 2026 12:34:45 +0000 Subject: [PATCH 09/12] Reuse webview root authorization for direct resources --- .../webview/common/resourceLoading.ts | 33 ++++++++++++++++ .../webview/common/webviewManagerService.ts | 1 + .../electron-main/webviewMainService.ts | 3 ++ .../electron-main/webviewProtocolProvider.ts | 39 ++++++++++++++++++- .../webview/browser/resourceLoading.ts | 39 ++----------------- .../contrib/webview/browser/webviewElement.ts | 1 + .../electron-browser/webviewElement.ts | 2 + 7 files changed, 82 insertions(+), 36 deletions(-) create mode 100644 src/vs/platform/webview/common/resourceLoading.ts diff --git a/src/vs/platform/webview/common/resourceLoading.ts b/src/vs/platform/webview/common/resourceLoading.ts new file mode 100644 index 00000000000000..4cd0f9271df82f --- /dev/null +++ b/src/vs/platform/webview/common/resourceLoading.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isUNC } from '../../../base/common/extpath.js'; +import { Schemas } from '../../../base/common/network.js'; +import { URI } from '../../../base/common/uri.js'; +import { IUriIdentityService } from '../../uriIdentity/common/uriIdentity.js'; + +export function isWebviewResourceAllowed(resource: URI, roots: readonly URI[], uriIdentityService: IUriIdentityService): boolean { + const resourceWithoutQuery = resource.with({ query: '' }); + for (const root of roots) { + if (uriIdentityService.extUri.isEqual(root, resourceWithoutQuery, true)) { + return false; + } + + // Compare UNC paths case-insensitively. + if (root.scheme === Schemas.file && isUNC(root.fsPath)) { + if (resourceWithoutQuery.scheme === Schemas.file && isUNC(resourceWithoutQuery.fsPath) + && uriIdentityService.extUri.isEqualOrParent( + resourceWithoutQuery.with({ path: resourceWithoutQuery.path.toLowerCase(), authority: resourceWithoutQuery.authority.toLowerCase() }), + root.with({ path: root.path.toLowerCase(), authority: root.authority.toLowerCase() }), + true, + )) { + return true; + } + } else if (uriIdentityService.extUri.isEqualOrParent(resourceWithoutQuery, root, true)) { + return true; + } + } + return false; +} diff --git a/src/vs/platform/webview/common/webviewManagerService.ts b/src/vs/platform/webview/common/webviewManagerService.ts index 9fb7b437a0d0cc..a042a9e9e60371 100644 --- a/src/vs/platform/webview/common/webviewManagerService.ts +++ b/src/vs/platform/webview/common/webviewManagerService.ts @@ -59,6 +59,7 @@ export interface WebviewDocumentRegistration { readonly windowId: number; readonly html: string; readonly csp: string; + readonly roots: readonly UriComponents[]; } export interface WebviewResourceRequest { diff --git a/src/vs/platform/webview/electron-main/webviewMainService.ts b/src/vs/platform/webview/electron-main/webviewMainService.ts index c735270c608215..9b05600c334837 100644 --- a/src/vs/platform/webview/electron-main/webviewMainService.ts +++ b/src/vs/platform/webview/electron-main/webviewMainService.ts @@ -10,6 +10,7 @@ import { FindInFrameOptions, FoundInFrameResult, IWebviewManagerService, Webview import { WebviewProtocolProvider } from './webviewProtocolProvider.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { IFileService } from '../../files/common/files.js'; +import { IUriIdentityService } from '../../uriIdentity/common/uriIdentity.js'; export class WebviewMainService extends Disposable implements IWebviewManagerService { @@ -27,6 +28,7 @@ export class WebviewMainService extends Disposable implements IWebviewManagerSer constructor( @IFileService fileService: IFileService, + @IUriIdentityService uriIdentityService: IUriIdentityService, @IWindowsMainService private readonly windowsMainService: IWindowsMainService, ) { super(); @@ -34,6 +36,7 @@ export class WebviewMainService extends Disposable implements IWebviewManagerSer request => this._onDidRequestWebviewResource.fire(request), requestId => this._onDidCancelWebviewResource.fire(requestId), request => this._onDidRequestWebviewPortMapping.fire(request), + uriIdentityService, fileService, )); this._register(this.windowsMainService.onDidDestroyWindow(window => this.protocolProvider.unregisterWebviewWindow(window.id))); diff --git a/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts b/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts index 4eb50fda61e675..0ab34fbd589c2d 100644 --- a/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts +++ b/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts @@ -3,12 +3,15 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { protocol } from 'electron'; +import { net, protocol } from 'electron'; import { VSBuffer } from '../../../base/common/buffer.js'; import { IDisposable } from '../../../base/common/lifecycle.js'; import { AppResourcePath, COI, FileAccess, Schemas } from '../../../base/common/network.js'; import { URI } from '../../../base/common/uri.js'; import { IFileService } from '../../files/common/files.js'; +import { IUriIdentityService } from '../../uriIdentity/common/uriIdentity.js'; +import { getWebviewContentMimeType } from '../common/mimeTypes.js'; +import { isWebviewResourceAllowed } from '../common/resourceLoading.js'; import { WebviewDocumentRegistration, WebviewPortMappingRequest, WebviewResourceRequest, WebviewResourceResponse } from '../common/webviewManagerService.js'; @@ -35,6 +38,7 @@ export class WebviewProtocolProvider implements IDisposable { private readonly requestResource: (request: WebviewResourceRequest) => void, private readonly cancelResource: (requestId: number) => void, private readonly requestPortMapping: (request: WebviewPortMappingRequest) => void, + private readonly uriIdentityService: IUriIdentityService, @IFileService private readonly _fileService: IFileService ) { // Register the protocol for loading webview html @@ -245,6 +249,10 @@ export class WebviewProtocolProvider implements IDisposable { if (!resource) { return new Response(null, { status: 403 }); } + const localResponse = await this.tryLoadLocalFileResource(request, resource, document); + if (localResponse) { + return localResponse; + } if (this.pendingResources.size >= 128) { return new Response(null, { status: 429 }); } @@ -276,6 +284,35 @@ export class WebviewProtocolProvider implements IDisposable { }); } + private async tryLoadLocalFileResource(request: GlobalRequest, resource: URI, document: WebviewDocumentRegistration): Promise { + if (resource.scheme !== Schemas.file + || request.headers.has('range') + || request.headers.has('if-none-match') + || !isWebviewResourceAllowed(resource, document.roots.map(root => URI.revive(root)), this.uriIdentityService)) { + return undefined; + } + + try { + const response = await net.fetch(resource.toString(true), { + method: request.method, + signal: request.signal, + bypassCustomProtocolHandlers: true, + }); + const headers = new Headers(response.headers); + headers.set('Content-Type', getWebviewContentMimeType(resource)); + headers.set('Access-Control-Allow-Origin', '*'); + headers.set('Cross-Origin-Resource-Policy', 'cross-origin'); + headers.set('X-Content-Type-Options', 'nosniff'); + return new Response(request.method === 'HEAD' ? null : response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); + } catch { + return undefined; + } + } + private parseRange(value: string | null): { start: number; end?: number } | undefined { if (!value) { return undefined; } const match = /^bytes=(\d+)-(\d*)$/.exec(value); diff --git a/src/vs/workbench/contrib/webview/browser/resourceLoading.ts b/src/vs/workbench/contrib/webview/browser/resourceLoading.ts index 93aee448b07ee8..accf9c70ba9a6b 100644 --- a/src/vs/workbench/contrib/webview/browser/resourceLoading.ts +++ b/src/vs/workbench/contrib/webview/browser/resourceLoading.ts @@ -5,7 +5,6 @@ import { VSBufferReadableStream } from '../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { isUNC } from '../../../../base/common/extpath.js'; import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; import { FileOperationError, FileOperationResult, IFileService, IWriteFileOptions } from '../../../../platform/files/common/files.js'; @@ -13,6 +12,7 @@ import { ServicesAccessor } from '../../../../platform/instantiation/common/inst import { ILogService } from '../../../../platform/log/common/log.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { getWebviewContentMimeType } from '../../../../platform/webview/common/mimeTypes.js'; +import { isWebviewResourceAllowed } from '../../../../platform/webview/common/resourceLoading.js'; export namespace WebviewResourceResponse { export enum Type { Success, Failed, AccessDenied, NotModified } @@ -105,40 +105,9 @@ export function getResourceToLoad( roots: ReadonlyArray, uriIdentityService: IUriIdentityService, ): URI | undefined { - const requestUriNoQueryString = requestUri.with({ query: '' }); - for (const root of roots) { - if (containsResource(root, requestUriNoQueryString, uriIdentityService)) { - return normalizeResourcePath(requestUri); - } - } - - return undefined; -} - -function containsResource(root: URI, resource: URI, uriIdentityService: IUriIdentityService): boolean { - if (uriIdentityService.extUri.isEqual(root, resource, /* ignoreFragment */ true)) { - return false; - } - - // Compare unc paths case-insensitively - if (root.scheme === Schemas.file && isUNC(root.fsPath)) { - if (resource.scheme === Schemas.file && isUNC(resource.fsPath)) { - return uriIdentityService.extUri.isEqualOrParent( - resource.with({ - path: resource.path.toLowerCase(), - authority: resource.authority.toLowerCase() - }), - root.with({ - path: root.path.toLowerCase(), - authority: root.authority.toLowerCase() - }), - /* ignoreFragment */ true - ); - } - return false; - } - - return uriIdentityService.extUri.isEqualOrParent(resource, root, /* ignoreFragment */ true); + return isWebviewResourceAllowed(requestUri, roots, uriIdentityService) + ? normalizeResourcePath(requestUri) + : undefined; } function normalizeResourcePath(resource: URI): URI { diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index 80d6c228a83dd3..11083ccae760d1 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -694,6 +694,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi ...this._content, options: { ...this._content.options, localResourceRoots: resources } }; + this.onContentDidChange(); } public set state(state: string | undefined) { diff --git a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts index 0d3e4b27fa6e4a..7504f1f52026da 100644 --- a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts @@ -321,6 +321,7 @@ export class ElectronWebviewElement extends WebviewElement { html: this.content.html, allowScripts: this.content.options.allowScripts, allowForms: this.content.options.allowForms, + roots: this.content.options.localResourceRoots?.map(root => root.toString()), }); if (contentKey === this._directContentKey) { return; @@ -344,6 +345,7 @@ export class ElectronWebviewElement extends WebviewElement { windowId: this.windowId!, html: transformed.html, csp: transformed.csp, + roots: this.content.options.localResourceRoots || [], }); if (generation !== this._directGeneration || !this.element) { return; From 3dad2dae0711c7ce0a9ec13bd818d4760330e1fd Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Fri, 17 Jul 2026 14:26:41 +0000 Subject: [PATCH 10/12] Address direct webview review feedback --- src/vs/code/electron-main/app.ts | 12 +- .../webview/common/webviewManagerService.ts | 1 + .../electron-main/webviewMainService.ts | 13 +- .../electron-main/webviewProtocolProvider.ts | 10 +- .../webview/browser/pre/defaultStyles.ts | 105 +++++++++++++ .../contrib/webview/browser/pre/index.html | 102 +------------ .../contrib/webview/browser/webviewElement.ts | 12 +- .../electron-browser/webviewElement.ts | 143 ++++++++++++------ 8 files changed, 238 insertions(+), 160 deletions(-) create mode 100644 src/vs/workbench/contrib/webview/browser/pre/defaultStyles.ts diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 1addbf4d0ddb7d..cf1fe05fe92b4c 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -334,19 +334,11 @@ export class CodeApplication extends Disposable { if (!frame || !owner) { return false; } - let belongsToOwner = false; - for (let current: WebFrameMain | null = frame; current; current = current.parent) { - if (current === owner.webContents.mainFrame) { - belongsToOwner = true; - break; - } - } - if (!belongsToOwner) { + if (frame.frameTreeNodeId !== directDocument.frameTreeNodeId) { return false; } const route = `${Schemas.vscodeWebview}://${directDocument.extensionId.toLowerCase()}/${encodeURIComponent(directDocument.webviewId)}/`; - const isInitialNavigation = frame === owner.webContents.mainFrame - || frame.url === '' + const isInitialNavigation = frame.url === '' || frame.url === 'about:blank' || frame.url.startsWith(`${Schemas.vscodeFileResource}://`); return isInitialNavigation || frame.url.startsWith(route); diff --git a/src/vs/platform/webview/common/webviewManagerService.ts b/src/vs/platform/webview/common/webviewManagerService.ts index a042a9e9e60371..5cdcb22ac39b11 100644 --- a/src/vs/platform/webview/common/webviewManagerService.ts +++ b/src/vs/platform/webview/common/webviewManagerService.ts @@ -57,6 +57,7 @@ export interface WebviewDocumentRegistration { readonly extensionId: string; readonly webviewId: string; readonly windowId: number; + readonly frameName: string; readonly html: string; readonly csp: string; readonly roots: readonly UriComponents[]; diff --git a/src/vs/platform/webview/electron-main/webviewMainService.ts b/src/vs/platform/webview/electron-main/webviewMainService.ts index 9b05600c334837..679d79d1048ad0 100644 --- a/src/vs/platform/webview/electron-main/webviewMainService.ts +++ b/src/vs/platform/webview/electron-main/webviewMainService.ts @@ -43,7 +43,18 @@ export class WebviewMainService extends Disposable implements IWebviewManagerSer } public async registerWebviewDocument(document: WebviewDocumentRegistration): Promise { - this.protocolProvider.registerWebviewDocument(document); + const window = this.windowsMainService.getWindowById(document.windowId); + const mainFrame = window?.win?.webContents.mainFrame; + const frame = mainFrame?.framesInSubtree.find(frame => { + return frame.parent === mainFrame && frame.name === document.frameName; + }); + if (!frame) { + throw new Error(`Unknown direct webview frame: ${document.frameName}`); + } + this.protocolProvider.registerWebviewDocument({ + ...document, + frameTreeNodeId: frame.frameTreeNodeId, + }); } public async unregisterWebviewDocument(extensionId: string, webviewId: string): Promise { diff --git a/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts b/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts index 0ab34fbd589c2d..2f3bf72c60c83d 100644 --- a/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts +++ b/src/vs/platform/webview/electron-main/webviewProtocolProvider.ts @@ -14,15 +14,19 @@ import { getWebviewContentMimeType } from '../common/mimeTypes.js'; import { isWebviewResourceAllowed } from '../common/resourceLoading.js'; import { WebviewDocumentRegistration, WebviewPortMappingRequest, WebviewResourceRequest, WebviewResourceResponse } from '../common/webviewManagerService.js'; +export interface RegisteredWebviewDocument extends WebviewDocumentRegistration { + readonly frameTreeNodeId: number; +} export class WebviewProtocolProvider implements IDisposable { private static instance: WebviewProtocolProvider | undefined; - private static readonly documents = new Map(); + private static readonly documents = new Map(); private static validWebviewFilePaths = new Map([ ['/index.html', { mime: 'text/html' }], ['/fake.html', { mime: 'text/html' }], ['/service-worker.js', { mime: 'application/javascript' }], + ['/defaultStyles.js', { mime: 'application/javascript' }], ]); private readonly pendingResources = new Map void; @@ -60,7 +64,7 @@ export class WebviewProtocolProvider implements IDisposable { if (WebviewProtocolProvider.instance === this) { WebviewProtocolProvider.instance = undefined; } } - public registerWebviewDocument(document: WebviewDocumentRegistration): void { + public registerWebviewDocument(document: RegisteredWebviewDocument): void { if (!/^[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*$/.test(document.extensionId.toLowerCase()) || !document.webviewId || document.webviewId.includes('/')) { throw new Error('Invalid direct webview route'); } @@ -137,7 +141,7 @@ export class WebviewProtocolProvider implements IDisposable { } } - public static getWebviewDocument(url: URI): WebviewDocumentRegistration | undefined { + public static getWebviewDocument(url: URI): RegisteredWebviewDocument | undefined { const match = /^\/([^/]+)\/(?:index\.html|_vscode\/resource\/)/.exec(url.path); if (!match) { return undefined; diff --git a/src/vs/workbench/contrib/webview/browser/pre/defaultStyles.ts b/src/vs/workbench/contrib/webview/browser/pre/defaultStyles.ts new file mode 100644 index 00000000000000..aa2a2cb913eeaf --- /dev/null +++ b/src/vs/workbench/contrib/webview/browser/pre/defaultStyles.ts @@ -0,0 +1,105 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export const defaultWebviewStyles = `@layer vscode-default { + html { + scrollbar-color: var(--vscode-scrollbarSlider-background) var(--vscode-editor-background); + } + + body { + overscroll-behavior-x: none; + background-color: transparent; + color: var(--vscode-editor-foreground); + font-family: var(--vscode-font-family); + font-weight: var(--vscode-font-weight); + font-size: var(--vscode-font-size); + margin: 0; + padding: 0 20px; + } + + img, video { + max-width: 100%; + max-height: 100%; + } + + a, a code { + color: var(--vscode-textLink-foreground); + } + + p > a { + text-decoration: var(--text-link-decoration); + } + + a:hover { + color: var(--vscode-textLink-activeForeground); + } + + a:focus, + input:focus, + select:focus, + textarea:focus { + outline: 1px solid -webkit-focus-ring-color; + outline-offset: -1px; + } + + code { + font-family: var(--monaco-monospace-font); + color: var(--vscode-textPreformat-foreground); + background-color: var(--vscode-textPreformat-background); + padding: 1px 3px; + border-radius: 4px; + } + + pre code { + padding: 0; + } + + blockquote { + background: var(--vscode-textBlockQuote-background); + border-color: var(--vscode-textBlockQuote-border); + } + + kbd { + background-color: var(--vscode-keybindingLabel-background); + color: var(--vscode-keybindingLabel-foreground); + border-style: solid; + border-width: 1px; + border-radius: 3px; + border-color: var(--vscode-keybindingLabel-border); + border-bottom-color: var(--vscode-keybindingLabel-bottomBorder); + box-shadow: inset 0 -1px 0 var(--vscode-widget-shadow); + vertical-align: middle; + padding: 1px 3px; + } + + ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + ::-webkit-scrollbar-corner { + background-color: var(--vscode-editor-background); + } + + ::-webkit-scrollbar-thumb { + background-color: var(--vscode-scrollbarSlider-background); + } + + ::-webkit-scrollbar-thumb:hover { + background-color: var(--vscode-scrollbarSlider-hoverBackground); + } + + ::-webkit-scrollbar-thumb:active { + background-color: var(--vscode-scrollbarSlider-activeBackground); + } + + ::highlight(find-highlight) { + background-color: var(--vscode-editor-findMatchHighlightBackground); + } + + ::highlight(current-find-highlight) { + background-color: var(--vscode-editor-findMatchBackground); + } +}`; diff --git a/src/vs/workbench/contrib/webview/browser/pre/index.html b/src/vs/workbench/contrib/webview/browser/pre/index.html index 1b722167644376..deec68ba304c29 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/index.html +++ b/src/vs/workbench/contrib/webview/browser/pre/index.html @@ -5,7 +5,7 @@ + content="default-src 'none'; script-src 'sha256-zBShrOUMiP4CBH0GIjJdnyNvN3K2xtcK0+OPPIZIZco=' 'self'; frame-src 'self'; style-src 'unsafe-inline';"> + import { defaultWebviewStyles } from './defaultStyles.js'; + const isSafari = ( navigator.vendor && navigator.vendor.indexOf('Apple') > -1 && navigator.userAgent && @@ -100,103 +102,7 @@ const defaultStyles = document.createElement('style'); defaultStyles.id = '_defaultStyles'; - defaultStyles.textContent = ` - @layer vscode-default { - html { - scrollbar-color: var(--vscode-scrollbarSlider-background) var(--vscode-editor-background); - } - - body { - overscroll-behavior-x: none; - background-color: transparent; - color: var(--vscode-editor-foreground); - font-family: var(--vscode-font-family); - font-weight: var(--vscode-font-weight); - font-size: var(--vscode-font-size); - margin: 0; - padding: 0 20px; - } - - img, video { - max-width: 100%; - max-height: 100%; - } - - a, a code { - color: var(--vscode-textLink-foreground); - } - - p > a { - text-decoration: var(--text-link-decoration); - } - - a:hover { - color: var(--vscode-textLink-activeForeground); - } - - a:focus, - input:focus, - select:focus, - textarea:focus { - outline: 1px solid -webkit-focus-ring-color; - outline-offset: -1px; - } - - code { - font-family: var(--monaco-monospace-font); - color: var(--vscode-textPreformat-foreground); - background-color: var(--vscode-textPreformat-background); - padding: 1px 3px; - border-radius: 4px; - } - - pre code { - padding: 0; - } - - blockquote { - background: var(--vscode-textBlockQuote-background); - border-color: var(--vscode-textBlockQuote-border); - } - - kbd { - background-color: var(--vscode-keybindingLabel-background); - color: var(--vscode-keybindingLabel-foreground); - border-style: solid; - border-width: 1px; - border-radius: 3px; - border-color: var(--vscode-keybindingLabel-border); - border-bottom-color: var(--vscode-keybindingLabel-bottomBorder); - box-shadow: inset 0 -1px 0 var(--vscode-widget-shadow); - vertical-align: middle; - padding: 1px 3px; - } - - ::-webkit-scrollbar { - width: 10px; - height: 10px; - } - - ::-webkit-scrollbar-corner { - background-color: var(--vscode-editor-background); - } - - ::-webkit-scrollbar-thumb { - background-color: var(--vscode-scrollbarSlider-background); - } - ::-webkit-scrollbar-thumb:hover { - background-color: var(--vscode-scrollbarSlider-hoverBackground); - } - ::-webkit-scrollbar-thumb:active { - background-color: var(--vscode-scrollbarSlider-activeBackground); - } - ::highlight(find-highlight) { - background-color: var(--vscode-editor-findMatchHighlightBackground); - } - ::highlight(current-find-highlight) { - background-color: var(--vscode-editor-findMatchBackground); - } - }`; + defaultStyles.textContent = defaultWebviewStyles; /** * @param {boolean} allowMultipleAPIAcquire diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index 11083ccae760d1..07d32f494fbfff 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -12,7 +12,7 @@ import { promiseWithResolvers, ThrottledDelayer } from '../../../../base/common/ import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { Lazy } from '../../../../base/common/lazy.js'; -import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { COI } from '../../../../base/common/network.js'; import { observableValue } from '../../../../base/common/observable.js'; import { listenStream } from '../../../../base/common/stream.js'; @@ -75,6 +75,7 @@ interface WebviewActionContext { const webviewIdContext = 'webviewId'; export class WebviewElement extends Disposable implements IWebviewElement, WebviewFindDelegate { + private readonly _directMessageHandler = this._register(new MutableDisposable()); protected readonly id = generateUuid(); private _resourceId: string | undefined; @@ -527,7 +528,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi } protected _registerMessageHandler(targetWindow: CodeWindow) { - const subscription = this._register(addDisposableListener(targetWindow, 'message', (e: MessageEvent) => { + const subscription = addDisposableListener(targetWindow, 'message', (e: MessageEvent) => { if (!this._encodedWebviewOrigin || e?.data?.target !== this.id) { return; } @@ -570,7 +571,12 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi subscription.dispose(); } - })); + }); + if (this.useSingleIframe) { + this._directMessageHandler.value = subscription; + } else { + this._register(subscription); + } } protected isValidWebviewReady(_data: unknown): boolean { return true; } diff --git a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts index 7504f1f52026da..e5a0145d94cbb2 100644 --- a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts @@ -29,6 +29,7 @@ import { IWorkbenchEnvironmentService } from '../../../services/environment/comm import { WebviewThemeDataProvider } from '../browser/themeing.js'; import { WebviewInitInfo } from '../browser/webview.js'; import { WebviewElement } from '../browser/webviewElement.js'; +import { defaultWebviewStyles } from '../browser/pre/defaultStyles.js'; import { WebviewResourceResponse } from '../browser/resourceLoading.js'; import { WindowIgnoreMenuShortcutsManager } from './windowIgnoreMenuShortcutsManager.js'; @@ -95,26 +96,46 @@ const singleIframeBootstrap = String.raw`(() => { window.addEventListener('dragenter', event => { if (!event.defaultPrevented && !event.shiftKey && hasOnlyFiles(event)) { post('drag-start'); } }); window.addEventListener('dragover', event => { event.preventDefault(); if (hasOnlyFiles(event)) { post('drag', { shiftKey: event.shiftKey }); } }); window.addEventListener('drop', event => event.preventDefault()); - window.addEventListener('contextmenu', event => post('did-context-menu', { clientX: event.clientX, clientY: event.clientY, context: {} })); + window.addEventListener('contextmenu', event => { + if (event.defaultPrevented) { return; } + event.preventDefault(); + let context = {}; + let element = event.target instanceof Element ? event.target : null; + while (element) { + element = element.closest('[data-vscode-context]'); + if (!element) { break; } + try { context = { ...JSON.parse(element.getAttribute('data-vscode-context')), ...context }; } + catch (error) { console.error("Error parsing 'data-vscode-context' as json", element, error); } + element = element.parentElement; + } + post('did-context-menu', { clientX: event.clientX, clientY: event.clientY, context }); + }); document.addEventListener('click', event => { + if (!event.isTrusted) { return; } const anchor = event.target instanceof Element ? event.target.closest('a[href]') : null; - if (anchor) { event.preventDefault(); post('did-click-link', { uri: anchor.href }); } + if (!anchor) { return; } + const href = anchor.getAttribute('href'); + const base = document.querySelector('base'); + if (href === '#') { + window.scrollTo(0, 0); + } else if (anchor.hash && (href === anchor.hash || (base && anchor.href === base.href + anchor.hash))) { + const fragment = anchor.hash.slice(1); + const decodedFragment = decodeURIComponent(fragment); + const scrollTarget = document.getElementById(fragment) ?? document.getElementById(decodedFragment); + if (scrollTarget) { + scrollTarget.scrollIntoView(); + } else if (decodedFragment.toLowerCase() === 'top') { + window.scrollTo(0, 0); + } + } else { + post('did-click-link', { uri: anchor.href }); + } + event.preventDefault(); }); parent.postMessage({ target: bootstrap.target, channel: 'webview-ready', data: { generation: bootstrap.generation } }, '*', [channel.port2]); })();`; -const singleIframeDefaultStyles = `@layer vscode-default { - html { scrollbar-color: var(--vscode-scrollbarSlider-background) var(--vscode-editor-background); } - body { overscroll-behavior-x: none; background-color: transparent; color: var(--vscode-editor-foreground); font-family: var(--vscode-font-family); font-weight: var(--vscode-font-weight); font-size: var(--vscode-font-size); margin: 0; padding: 0 20px; } - img, video { max-width: 100%; max-height: 100%; } - a, a code { color: var(--vscode-textLink-foreground); } - a:hover { color: var(--vscode-textLink-activeForeground); } - a:focus, input:focus, select:focus, textarea:focus { outline: 1px solid -webkit-focus-ring-color; outline-offset: -1px; } - code { font-family: var(--monaco-monospace-font); color: var(--vscode-textPreformat-foreground); background-color: var(--vscode-textPreformat-background); padding: 1px 3px; border-radius: 4px; } - pre code { padding: 0; } -}`; - const singleIframeHtmlPolicy = createTrustedTypesPolicy('singleIframeWebview', { createHTML: value => value, createScript: value => value, @@ -137,6 +158,7 @@ export class ElectronWebviewElement extends WebviewElement { private _directHandshakeId: string | undefined; private _directContentKey: string | undefined; private _directUpdate: Promise = Promise.resolve(); + private _directDisposed = false; private readonly _directResourceRequests = new Map(); protected override get platform() { return 'electron'; } @@ -148,7 +170,7 @@ export class ElectronWebviewElement extends WebviewElement { @ITunnelService tunnelService: ITunnelService, @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, @IRemoteAuthorityResolverService remoteAuthorityResolverService: IRemoteAuthorityResolverService, - @ILogService logService: ILogService, + @ILogService private readonly _directLogService: ILogService, @IConfigurationService configurationService: IConfigurationService, @IMainProcessService mainProcessService: IMainProcessService, @INotificationService notificationService: INotificationService, @@ -158,7 +180,7 @@ export class ElectronWebviewElement extends WebviewElement { ) { super(initInfo, webviewThemeDataProvider, configurationService, contextMenuService, notificationService, environmentService, - logService, remoteAuthorityResolverService, tunnelService, accessibilityService, instantiationService); + _directLogService, remoteAuthorityResolverService, tunnelService, accessibilityService, instantiationService); this._webviewKeyboardHandler = new WindowIgnoreMenuShortcutsManager(configurationService, mainProcessService, _nativeHostService); @@ -197,6 +219,8 @@ export class ElectronWebviewElement extends WebviewElement { override dispose(): void { // Make sure keyboard handler knows it closed (#71800) this._webviewKeyboardHandler.didBlur(); + this._directDisposed = true; + this._directGeneration++; if (this.extension?.useSingleIframe && this.resourceId) { void this._webviewMainService.unregisterWebviewDocument(this.extension.id.value, this.resourceId); @@ -313,62 +337,74 @@ export class ElectronWebviewElement extends WebviewElement { const extensionId = this.extension?.id.value.toLowerCase(); const webviewId = this.resourceId; const targetWindow = this._directTargetWindow; - if (!extensionId || !webviewId || !targetWindow || typeof this.windowId !== 'number' || !this.element) { + const windowId = this.windowId; + if (this._directDisposed || !extensionId || !webviewId || !targetWindow || typeof windowId !== 'number' || !this.element) { return; } + const content = this.content; const contentKey = JSON.stringify({ - html: this.content.html, - allowScripts: this.content.options.allowScripts, - allowForms: this.content.options.allowForms, - roots: this.content.options.localResourceRoots?.map(root => root.toString()), + html: content.html, + allowScripts: content.options.allowScripts, + allowForms: content.options.allowForms, + roots: content.options.localResourceRoots?.map(root => root.toString()), }); if (contentKey === this._directContentKey) { return; } this._directContentKey = contentKey; - this._directUpdate = this._directUpdate.then(async () => { - const generation = ++this._directGeneration; - const handshakeId = generateUuid(); - this._directHandshakeId = handshakeId; - const transformed = await this.transformDirectHtml(this.content.html, !!this.content.options.allowScripts, { + const generation = ++this._directGeneration; + const handshakeId = generateUuid(); + this._directHandshakeId = handshakeId; + if (prepareForNavigation) { + this.prepareForDirectNavigation(targetWindow); + this.style(); + } + + const update = this._directUpdate.then(async () => { + const transformed = await this.transformDirectHtml(content.html, !!content.options.allowScripts, { target: this.id, generation: handshakeId, - }); - if (generation !== this._directGeneration) { + }, content.state, content.title); + if (this._directDisposed || generation !== this._directGeneration) { return; } await this._webviewMainService.registerWebviewDocument({ extensionId, webviewId, - windowId: this.windowId!, + windowId, + frameName: this.id, html: transformed.html, csp: transformed.csp, - roots: this.content.options.localResourceRoots || [], + roots: content.options.localResourceRoots || [], }); - if (generation !== this._directGeneration || !this.element) { + if (this._directDisposed || generation !== this._directGeneration || !this.element) { + await this._webviewMainService.unregisterWebviewDocument(extensionId, webviewId); return; } this.element.sandbox.remove('allow-same-origin', 'allow-forms', 'allow-downloads'); this.element.sandbox.add('allow-scripts', 'allow-pointer-lock'); - if (this.content.options.allowForms ?? this.content.options.allowScripts) { + if (content.options.allowForms ?? content.options.allowScripts) { this.element.sandbox.add('allow-forms'); } - if (this.content.options.allowScripts) { + if (content.options.allowScripts) { this.element.sandbox.add('allow-downloads'); } - if (prepareForNavigation) { - this.prepareForDirectNavigation(targetWindow); - this.style(); - } + this.element.title = content.title || ''; this.element.src = `${Schemas.vscodeWebview}://${extensionId}/${encodeURIComponent(webviewId)}/index.html`; }); + this._directUpdate = update.then(undefined, error => { + if (generation === this._directGeneration) { + this._directContentKey = undefined; + } + this._directLogService.error(`Webview(${this.id}): failed to update direct webview`, error); + }); await this._directUpdate; } - private async transformDirectHtml(html: string, allowScripts: boolean, bootstrapData: { readonly target: string; readonly generation: string }): Promise<{ html: string; csp: string }> { + private async transformDirectHtml(html: string, allowScripts: boolean, bootstrapData: { readonly target: string; readonly generation: string }, persistedState: string | undefined, title: string | undefined): Promise<{ html: string; csp: string }> { const source = html || ''; const trustedSource = singleIframeHtmlPolicy?.createHTML(source) ?? source; const parsedDocument = new DOMParser().parseFromString(trustedSource as string, 'text/html'); @@ -384,9 +420,15 @@ export class ElectronWebviewElement extends WebviewElement { let csp = policies[0].getAttribute('content')!.trim(); policies[0].remove(); const hash = await this.contentHash(singleIframeBootstrap); - const styleHash = await this.contentHash(singleIframeDefaultStyles); + const styleHash = await this.contentHash(defaultWebviewStyles); csp = this.addHash(csp, 'script-src', hash); + if (this.hasDirective(csp, 'script-src-elem')) { + csp = this.addHash(csp, 'script-src-elem', hash); + } csp = this.addHash(csp, 'style-src', styleHash); + if (this.hasDirective(csp, 'style-src-elem')) { + csp = this.addHash(csp, 'style-src-elem', styleHash); + } if (!allowScripts) { csp += `, script-src ${hash}; script-src-attr 'none'`; } @@ -399,13 +441,13 @@ export class ElectronWebviewElement extends WebviewElement { parsedDocument.head.prepend(bootstrap); const defaultStyles = parsedDocument.createElement('style'); defaultStyles.id = '_defaultStyles'; - defaultStyles.textContent = singleIframeDefaultStyles; + defaultStyles.textContent = defaultWebviewStyles; parsedDocument.head.prepend(defaultStyles); - const state = parsedDocument.createElement('meta'); - state.name = 'vscode-webview-state'; - state.content = this.content.state ? encodeURIComponent(this.content.state) : ''; - parsedDocument.head.prepend(state); - parsedDocument.title = this.content.title || ''; + const stateElement = parsedDocument.createElement('meta'); + stateElement.name = 'vscode-webview-state'; + stateElement.content = persistedState ? encodeURIComponent(persistedState) : ''; + parsedDocument.head.prepend(stateElement); + parsedDocument.title = title || ''; return { html: `\n${parsedDocument.documentElement.outerHTML}`, csp }; } @@ -418,7 +460,7 @@ export class ElectronWebviewElement extends WebviewElement { private addHash(csp: string, directive: string, hash: string): string { const directives = csp.split(';').map(value => value.trim()).filter(Boolean); - const index = directives.findIndex(value => value.toLowerCase().startsWith(`${directive} `)); + const index = directives.findIndex(value => value.split(/\s/, 1)[0].toLowerCase() === directive); if (index >= 0) { directives[index] += ` ${hash}`; } else { @@ -427,6 +469,17 @@ export class ElectronWebviewElement extends WebviewElement { return directives.join('; '); } + private hasDirective(csp: string, directive: string): boolean { + return csp.split(';').some(value => value.trim().split(/\s/, 1)[0].toLowerCase() === directive); + } + + public override setTitle(title: string): void { + super.setTitle(title); + if (this.useSingleIframe && this.element) { + this.element.title = title; + } + } + protected override webviewContentEndpoint(iframeId: string): string { return `${Schemas.vscodeWebview}://${iframeId}`; } From dfcabf83868359828e2457d5fef442e86910cc40 Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Fri, 17 Jul 2026 15:35:06 +0000 Subject: [PATCH 11/12] Preserve direct webview compatibility --- .../webview/common/resourceLoading.ts | 2 +- .../electron-browser/webviewElement.ts | 36 +++++++++++++++---- .../test/browser/resourceLoading.test.ts | 10 ++++++ 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/vs/platform/webview/common/resourceLoading.ts b/src/vs/platform/webview/common/resourceLoading.ts index 4cd0f9271df82f..ea6445688ac742 100644 --- a/src/vs/platform/webview/common/resourceLoading.ts +++ b/src/vs/platform/webview/common/resourceLoading.ts @@ -12,7 +12,7 @@ export function isWebviewResourceAllowed(resource: URI, roots: readonly URI[], u const resourceWithoutQuery = resource.with({ query: '' }); for (const root of roots) { if (uriIdentityService.extUri.isEqual(root, resourceWithoutQuery, true)) { - return false; + continue; } // Compare UNC paths case-insensitively. diff --git a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts index e5a0145d94cbb2..ce8c22d8b7939e 100644 --- a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts @@ -42,11 +42,10 @@ const singleIframeBootstrap = String.raw`(() => { const stateElement = document.querySelector('meta[name="vscode-webview-state"]'); let state = stateElement?.content ? JSON.parse(decodeURIComponent(stateElement.content)) : undefined; stateElement?.remove(); - const pending = []; const post = (channelName, data, transfer = []) => channel.port1.postMessage({ channel: channelName, data }, transfer); globalThis.acquireVsCodeApi = () => { - if (acquired) { throw new Error('An instance of the VS Code API has already been acquired'); } + if (acquired && !bootstrap.allowMultipleAPIAcquire) { throw new Error('An instance of the VS Code API has already been acquired'); } acquired = true; return Object.freeze({ postMessage(message, transfer) { post('onmessage', { message, transfer }, transfer); }, @@ -159,6 +158,7 @@ export class ElectronWebviewElement extends WebviewElement { private _directContentKey: string | undefined; private _directUpdate: Promise = Promise.resolve(); private _directDisposed = false; + private _directRegisteredDocument: { readonly extensionId: string; readonly webviewId: string; readonly windowId: number } | undefined; private readonly _directResourceRequests = new Map(); protected override get platform() { return 'electron'; } @@ -222,9 +222,7 @@ export class ElectronWebviewElement extends WebviewElement { this._directDisposed = true; this._directGeneration++; - if (this.extension?.useSingleIframe && this.resourceId) { - void this._webviewMainService.unregisterWebviewDocument(this.extension.id.value, this.resourceId); - } + void this.unregisterDirectDocument(); for (const request of this._directResourceRequests.values()) { request.dispose(true); } @@ -344,7 +342,11 @@ export class ElectronWebviewElement extends WebviewElement { const content = this.content; const contentKey = JSON.stringify({ + extensionId, + webviewId, + windowId, html: content.html, + allowMultipleAPIAcquire: content.options.allowMultipleAPIAcquire, allowScripts: content.options.allowScripts, allowForms: content.options.allowForms, roots: content.options.localResourceRoots?.map(root => root.toString()), @@ -366,10 +368,21 @@ export class ElectronWebviewElement extends WebviewElement { const transformed = await this.transformDirectHtml(content.html, !!content.options.allowScripts, { target: this.id, generation: handshakeId, + allowMultipleAPIAcquire: !!content.options.allowMultipleAPIAcquire, }, content.state, content.title); if (this._directDisposed || generation !== this._directGeneration) { return; } + const registeredDocument = this._directRegisteredDocument; + if (registeredDocument + && (registeredDocument.extensionId !== extensionId + || registeredDocument.webviewId !== webviewId + || registeredDocument.windowId !== windowId)) { + await this.unregisterDirectDocument(); + if (this._directDisposed || generation !== this._directGeneration) { + return; + } + } await this._webviewMainService.registerWebviewDocument({ extensionId, webviewId, @@ -381,8 +394,10 @@ export class ElectronWebviewElement extends WebviewElement { }); if (this._directDisposed || generation !== this._directGeneration || !this.element) { await this._webviewMainService.unregisterWebviewDocument(extensionId, webviewId); + this._directRegisteredDocument = undefined; return; } + this._directRegisteredDocument = { extensionId, webviewId, windowId }; this.element.sandbox.remove('allow-same-origin', 'allow-forms', 'allow-downloads'); this.element.sandbox.add('allow-scripts', 'allow-pointer-lock'); @@ -404,7 +419,16 @@ export class ElectronWebviewElement extends WebviewElement { await this._directUpdate; } - private async transformDirectHtml(html: string, allowScripts: boolean, bootstrapData: { readonly target: string; readonly generation: string }, persistedState: string | undefined, title: string | undefined): Promise<{ html: string; csp: string }> { + private async unregisterDirectDocument(): Promise { + const registeredDocument = this._directRegisteredDocument; + if (!registeredDocument) { + return; + } + this._directRegisteredDocument = undefined; + await this._webviewMainService.unregisterWebviewDocument(registeredDocument.extensionId, registeredDocument.webviewId); + } + + private async transformDirectHtml(html: string, allowScripts: boolean, bootstrapData: { readonly target: string; readonly generation: string; readonly allowMultipleAPIAcquire: boolean }, persistedState: string | undefined, title: string | undefined): Promise<{ html: string; csp: string }> { const source = html || ''; const trustedSource = singleIframeHtmlPolicy?.createHTML(source) ?? source; const parsedDocument = new DOMParser().parseFromString(trustedSource as string, 'text/html'); diff --git a/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts b/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts index 1e55be9211d2af..cd33b366deaf3e 100644 --- a/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts +++ b/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts @@ -236,6 +236,16 @@ suite('Webview Resource Loading - getResourceToLoad', () => { assert.strictEqual(result?.toString(), resource.toString()); }); + test('Returns resource equal to one root when it is contained by another root', () => { + const roots = [ + URI.file('/home/user/project/subdir'), + URI.file('/home/user/project') + ]; + const resource = URI.file('/home/user/project/subdir'); + const result = getResourceToLoad(resource, roots, uriIdentityService); + assert.strictEqual(result?.toString(), resource.toString()); + }); + test('handles empty roots array', () => { const resource = URI.file('/home/user/project/file.txt'); const result = getResourceToLoad(resource, [], uriIdentityService); From 234e07270092f76ecd7ab72e8f11e619d91fb68e Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Fri, 17 Jul 2026 15:53:45 +0000 Subject: [PATCH 12/12] Enable direct webviews for Markdown previews --- extensions/markdown-language-features/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index aedacbaf21c3f3..3691f82a562598 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -13,7 +13,8 @@ "documentDiff", "documentSyntaxHighlighting", "textEditorDiffInformation", - "customEditorPriority" + "customEditorPriority", + "webviewNoServiceWorker" ], "engines": { "vscode": "^1.70.0"