6.8 KB221 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import * as crypto from 'crypto';
9import * as vscode from 'vscode';
10
11export const devPort = 3015;
12export const devUri = `http://localhost:${devPort}`;
13
14export function getWebviewOptions(
15 context: vscode.ExtensionContext,
16 distFolder: string,
17): vscode.WebviewOptions & vscode.WebviewPanelOptions {
18 return {
19 enableScripts: true,
20 retainContextWhenHidden: true,
21 // Restrict the webview to only loading content from our extension's output directory.
22 localResourceRoots: [
23 vscode.Uri.joinPath(context.extensionUri, distFolder),
24 vscode.Uri.parse(devUri),
25 ],
26 portMapping: [{webviewPort: devPort, extensionHostPort: devPort}],
27 };
28}
29
30/**
31 * Get any extra styles to inject into the webview.
32 * Important: this is injected into the HTML directly, and should not
33 * use any user-controlled data that could be used maliciously.
34 */
35function getVSCodeCompatibilityStyles(): string {
36 const globalStyles = new Map();
37
38 const fontFeatureSettings = vscode.workspace
39 .getConfiguration('editor')
40 .get<string | boolean>('fontLigatures');
41 const validFontFeaturesRegex = /^[0-9a-zA-Z"',\-_ ]*$/;
42 if (fontFeatureSettings === true) {
43 // no need to specify specific additional settings
44 } else if (
45 !fontFeatureSettings ||
46 typeof fontFeatureSettings !== 'string' ||
47 !validFontFeaturesRegex.test(fontFeatureSettings)
48 ) {
49 globalStyles.set('font-variant-ligatures', 'none');
50 } else {
51 globalStyles.set('font-feature-settings', fontFeatureSettings);
52 }
53
54 const tabSizeSettings = vscode.workspace.getConfiguration('editor').get<number>('tabSize');
55 if (typeof tabSizeSettings === 'number') {
56 globalStyles.set('--tab-size', tabSizeSettings);
57 }
58
59 const globalStylesFlat = Array.from(globalStyles, ([k, v]) => `${k}: ${v};`);
60 return `
61 html {
62 ${globalStylesFlat.join('\n')};
63 }`;
64}
65
66/**
67 * When built in dev mode using vite, files are not written to disk.
68 * In order to get files to load, we need to set up the server path ourself.
69 *
70 * Note: no CSPs in dev mode. This should not be used in production!
71 */
72function devModeHtmlForWebview(
73 /**
74 * CSS to inject into the HTML in a <style> tag
75 */
76 extraStyles: string,
77 /**
78 * javascript to inject into the HTML in a <script> tag
79 * IMPORTANT: this MUST be sanitized to avoid XSS attacks
80 */
81 initialScript: (nonce: string) => string,
82 devModeScripts: Array<string>,
83 rootClass: string,
84 placeholderHtml?: string,
85) {
86 return `<!DOCTYPE html>
87 <html lang="en">
88 <head>
89 <meta charset="UTF-8">
90 <meta name="viewport" content="width=device-width, initial-scale=1.0">
91 <base href="${vscode.Uri.parse(devUri)}">
92
93 <!-- Hot reloading code from Vite. Normally, vite injects this into the HTML.
94 But since we have to load this statically, we insert it manually here.
95 See https://github.com/vitejs/vite/blob/734a9e3a4b9a0824a5ba4a5420f9e1176ce74093/docs/guide/backend-integration.md?plain=1#L50-L56 -->
96 <script type="module">
97 import RefreshRuntime from "/@react-refresh"
98 RefreshRuntime.injectIntoGlobalHook(window)
99 window.$RefreshReg$ = () => {}
100 window.$RefreshSig$ = () => (type) => type
101 window.__vite_plugin_react_preamble_installed__ = true
102 </script>
103 <script type="module" src="/@vite/client"></script>
104 <style>
105 ${getVSCodeCompatibilityStyles()}
106 ${extraStyles}
107 </style>
108 ${initialScript('')}
109 ${devModeScripts.map(script => `<script type="module" src="${script}"></script>`).join('\n')}
110 </head>
111 <body>
112 <div id="root" class="${rootClass}">
113 ${placeholderHtml ?? 'loading (dev mode)'}
114 </div>
115 </body>
116 </html>`;
117}
118
119const IS_DEV_BUILD = process.env.NODE_ENV === 'development';
120export function htmlForWebview({
121 webview,
122 context,
123 extraStyles,
124 initialScript,
125 title,
126 rootClass,
127 extensionRelativeBase,
128 entryPointFile,
129 cssEntryPointFile,
130 devModeScripts,
131 placeholderHtml,
132}: {
133 webview: vscode.Webview;
134 context: vscode.ExtensionContext;
135 /**
136 * CSS to inject into the HTML in a <style> tag
137 */
138 extraStyles: string;
139 /**
140 * javascript to inject into the HTML in a <script> tag
141 * IMPORTANT: this MUST be sanitized to avoid XSS attacks
142 */
143 initialScript: (nonce: string) => string;
144 /** <head>'s <title> of the webview */
145 title: string;
146 /** className to apply to the root <div> */
147 rootClass: string;
148 /** Base directory the webview loads from, where `/` in HTTP requests is relative to */
149 extensionRelativeBase: string;
150 /** Built entry point .js javascript file name to load, relative to extensionRelativeBase */
151 entryPointFile: string;
152 /** Built bundle .css file name to load, relative to extensionRelativeBase */
153 cssEntryPointFile: string;
154 /** Entry point scripts used in dev mode, needed for hot reloading */
155 devModeScripts: Array<string>;
156 /** Placeholder HTML element to show while the webview is loading */
157 placeholderHtml?: string;
158}) {
159 // Only allow accessing resources relative to webview dir,
160 // and make paths relative to here.
161 const baseUri = webview.asWebviewUri(
162 vscode.Uri.joinPath(context.extensionUri, extensionRelativeBase),
163 );
164
165 if (IS_DEV_BUILD) {
166 return devModeHtmlForWebview(
167 extraStyles,
168 initialScript,
169 devModeScripts,
170 rootClass,
171 placeholderHtml,
172 );
173 }
174
175 const scriptUri = entryPointFile;
176
177 // Use a nonce to only allow specific scripts to be run
178 const nonce = getNonce();
179
180 const CSP = [
181 `default-src ${webview.cspSource}`,
182 `style-src ${webview.cspSource} 'unsafe-inline'`,
183 // vscode-webview-ui needs to use style-src-elem without the nonce
184 `style-src-elem ${webview.cspSource} 'unsafe-inline'`,
185 `font-src ${webview.cspSource} data:`,
186 `img-src ${webview.cspSource} https: data:`,
187 `script-src ${webview.cspSource} 'nonce-${nonce}' 'wasm-unsafe-eval'`,
188 `script-src-elem ${webview.cspSource} 'nonce-${nonce}'`,
189 `worker-src ${webview.cspSource} 'nonce-${nonce}' blob:`,
190 ].join('; ');
191
192 return `<!DOCTYPE html>
193 <html lang="en">
194 <head>
195 <meta charset="UTF-8">
196 <meta http-equiv="Content-Security-Policy" content="${CSP}">
197 <meta name="viewport" content="width=device-width, initial-scale=1.0">
198 <base href="${baseUri}/">
199 <title>${title}</title>
200
201 <link href="${cssEntryPointFile}" rel="stylesheet">
202 <link href="res/stylex.css" rel="stylesheet">
203 <style>
204 ${getVSCodeCompatibilityStyles()}
205 ${extraStyles}
206 </style>
207 ${initialScript(nonce)}
208 <script type="module" defer="defer" nonce="${nonce}" src="${scriptUri}"></script>
209 </head>
210 <body>
211 <div id="root" class="${rootClass}">
212 ${placeholderHtml ?? 'loading...'}
213 </div>
214 </body>
215 </html>`;
216}
217
218function getNonce(): string {
219 return crypto.randomBytes(16).toString('base64');
220}
221