From 8f58ed1d2f670abbd859bd90c508021d0371ae08 Mon Sep 17 00:00:00 2001 From: limbo Date: Mon, 21 Sep 2026 16:45:11 +0800 Subject: [PATCH] fix(webview): load markdown styles in Firefox --- CHANGELOG.md | 1 + patches/csp-hashes.diff | 4 +- patches/webview.diff | 160 ++++++++++++++++++++++++++++++++++++++- test/e2e/webview.test.ts | 25 ++++-- 4 files changed, 182 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f159ce410f1..b223fa061304 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ Code v99.99.999 - `--idle-timeout-seconds` was only validated when passed as `--idle-timeout-seconds=`; values of 60 or less passed as `--idle-timeout-seconds ` were silently accepted. +- Load local styles configured with `markdown.styles` in Firefox 149 and later. ## [4.138.0](https://github.com/coder/code-server/releases/tag/v4.138.0) - 2026-09-19 diff --git a/patches/csp-hashes.diff b/patches/csp-hashes.diff index 422ca3b61e6e..bb22fdcc8033 100644 --- a/patches/csp-hashes.diff +++ b/patches/csp-hashes.diff @@ -6,8 +6,8 @@ Index: code-server/lib/vscode/src/vs/workbench/contrib/webview/browser/pre/index -+ content="default-src 'none'; script-src 'sha256-24QqbpzqcqSyIGvg/ZwFRy5pPlhmVBa8CkUREEIUnBw=' 'self'; frame-src 'self'; style-src 'unsafe-inline';"> +- content="default-src 'none'; script-src 'sha256-FFQoOVVa2tOE3uqUvirwaMNT20TZmrHcL2aaOjJ8BUo=' 'self'; connect-src 'self' https://*.vscode-cdn.net; frame-src 'self'; style-src 'unsafe-inline';"> ++ content="default-src 'none'; script-src 'sha256-u3FINRB6N9ZtXZVeE1TdGILlPsfDesEgZJhO81bfwi0=' 'self'; connect-src 'self' https://*.vscode-cdn.net; frame-src 'self'; style-src 'unsafe-inline';"> + + ++ content="default-src 'none'; script-src 'sha256-FFQoOVVa2tOE3uqUvirwaMNT20TZmrHcL2aaOjJ8BUo=' 'self'; connect-src 'self' https://*.vscode-cdn.net; frame-src 'self'; style-src 'unsafe-inline';"> + + + >} + */ +- function toContentHtml(data) { ++ async function loadFirefoxStylesheets(contentDocument) { ++ if (!isFirefox) { ++ return []; ++ } ++ ++ const resourceBaseAuthority = searchParams.get('vscode-resource-base-authority'); ++ if (!resourceBaseAuthority) { ++ return []; ++ } ++ ++ const stylesheets = []; ++ for (const link of contentDocument.querySelectorAll('link')) { ++ if (!link.relList.contains('stylesheet')) { ++ continue; ++ } ++ ++ let resourceUrl; ++ try { ++ resourceUrl = new URL(link.href, link.baseURI); ++ } catch { ++ continue; ++ } ++ ++ if (resourceUrl.protocol !== 'https:' || !resourceUrl.hostname.endsWith('.' + resourceBaseAuthority)) { ++ continue; ++ } ++ ++ try { ++ const response = await fetch(resourceUrl); ++ if (!response.ok) { ++ continue; ++ } ++ ++ stylesheets.push({ ++ href: resourceUrl.toString(), ++ css: await response.text(), ++ media: link.media, ++ disabled: link.disabled, ++ }); ++ link.remove(); ++ } catch { ++ // Leave the link in place so the webview reports the original load failure. ++ } ++ } ++ ++ return stylesheets; ++ } ++ ++ /** ++ * @param {import('../webviewMessages').UpdateContentEvent} data ++ * @returns {Promise<{ html: string, stylesheets: Array<{ href: string, css: string, media: string, disabled: boolean }> }>} ++ */ ++ async function toContentHtml(data) { + const options = data.options; + const text = data.contents; + const newDocument = new DOMParser().parseFromString(text, 'text/html'); ++ const stylesheets = await loadFirefoxStylesheets(newDocument); + + newDocument.querySelectorAll('a').forEach(a => { + if (!a.title) { +@@ -904,7 +966,10 @@ + + // set DOCTYPE for newDocument explicitly as DOMParser.parseFromString strips it off + // and DOCTYPE is needed in the iframe to ensure that the user agent stylesheet is correctly overridden +- return '\n' + newDocument.documentElement.outerHTML; ++ return { ++ html: '\n' + newDocument.documentElement.outerHTML, ++ stylesheets, ++ }; + } + + // Also forward events before the contents of the webview have loaded +@@ -979,7 +1044,10 @@ + } + + const options = data.options; +- const newDocument = toContentHtml(data); ++ const { html: newDocument, stylesheets } = await toContentHtml(data); ++ if (currentUpdateId !== updateId) { ++ return; ++ } + + const initialStyleVersion = styleVersion; + +@@ -1055,15 +1123,44 @@ + /** + * @param {Document} contentDocument + */ ++ async function applyFirefoxStylesheets(contentDocument) { ++ if (!stylesheets.length || !contentDocument.defaultView) { ++ return; ++ } ++ ++ /** @type {CSSStyleSheet[]} */ ++ const sheets = []; ++ for (const stylesheet of stylesheets) { ++ const sheet = new contentDocument.defaultView.CSSStyleSheet({ ++ baseURL: stylesheet.href, ++ media: stylesheet.media, ++ disabled: stylesheet.disabled, ++ }); ++ try { ++ await sheet.replace(stylesheet.css); ++ } catch (error) { ++ console.error(`Failed to apply stylesheet '${stylesheet.href}': ${error}`); ++ continue; ++ } ++ sheets.push(sheet); ++ } ++ ++ contentDocument.adoptedStyleSheets = [...contentDocument.adoptedStyleSheets, ...sheets]; ++ } ++ ++ /** ++ * @param {Document} contentDocument ++ */ + function onFrameLoaded(contentDocument) { + perfMark('content/innerFrameLoaded') + + // Workaround for https://bugs.chromium.org/p/chromium/issues/detail?id=978325 +- setTimeout(() => { ++ setTimeout(async () => { + contentDocument.open(); + contentDocument.write(newDocument); + contentDocument.close(); + hookupOnLoadHandlers(newFrame); ++ await applyFirefoxStylesheets(contentDocument); + perfMark('content/wroteInnerContent') + + if (initialStyleVersion !== styleVersion) { Index: code-server/lib/vscode/src/vs/workbench/services/extensions/worker/webWorkerExtensionHostIframe.html =================================================================== --- code-server.orig/lib/vscode/src/vs/workbench/services/extensions/worker/webWorkerExtensionHostIframe.html diff --git a/test/e2e/webview.test.ts b/test/e2e/webview.test.ts index e5a783f37322..80f0a47f4ebf 100644 --- a/test/e2e/webview.test.ts +++ b/test/e2e/webview.test.ts @@ -3,12 +3,23 @@ import * as path from "path" import { describe, test, expect } from "./baseFixture" describe("Webviews", ["--disable-workspace-trust"], {}, () => { - test("should preview a Markdown file", async ({ codeServerPage }) => { - // Create Markdown file + test("should preview a Markdown file with custom styles", async ({ codeServerPage }) => { const heading = "Hello world" const dir = await codeServerPage.workspaceDir const file = path.join(dir, "text.md") + const style = path.join(dir, "test.css") + const settingsPath = path.join(dir, "User/settings.json") + const settings = JSON.parse(await fs.readFile(settingsPath, "utf8")) + + settings["markdown.styles"] = ["test.css"] + await fs.writeFile(settingsPath, JSON.stringify(settings)) + await fs.writeFile(style, "h1 { color: rgb(1, 2, 3); }") await fs.writeFile(file, `# ${heading}`) + + // Reload so the workbench reads the updated user settings before rendering + // the Markdown preview. + await codeServerPage.page.reload() + await codeServerPage.reloadUntilEditorIsReady() await codeServerPage.openFile(file) // Open Preview @@ -18,8 +29,12 @@ describe("Webviews", ["--disable-workspace-trust"], {}, () => { // It's an iframe within an iframe // so we have to do .frameLocator twice - await expect( - codeServerPage.page.frameLocator("iframe.webview.ready").frameLocator("#active-frame").getByText("Hello world"), - ).toBeVisible() + const previewHeading = codeServerPage.page + .frameLocator("iframe.webview.ready") + .frameLocator("#active-frame") + .getByText(heading) + + await expect(previewHeading).toBeVisible() + await expect(previewHeading).toHaveCSS("color", "rgb(1, 2, 3)") }) })