Skip to content

Commit

Permalink
feat(iframe): support load webview by srcdoc (#4071)
Browse files Browse the repository at this point in the history
  • Loading branch information
bytemain authored Oct 10, 2024
1 parent 538e9ce commit 6643c17
Show file tree
Hide file tree
Showing 19 changed files with 472 additions and 93 deletions.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,13 @@
"add:node": "tsx ./scripts/add-node",
"add:browser": "tsx ./scripts/add-browser",
"build": "yarn run compile",
"build:all": "yarn run build && yarn run build:worker-host && yarn run build:ext-host && yarn run build:components && yarn build:monaco-worker",
"build:all": "yarn build:webview-prebuilt && yarn run build && yarn run build:worker-host && yarn run build:ext-host && yarn run build:components && yarn build:monaco-worker",
"compile": "cross-env NODE_ENV=production tsx ./scripts/build",
"build:worker-host": "cd packages/extension && yarn run compile:worker",
"build:ext-host": "cd packages/extension && yarn run build:ext-host",
"build:cli-engine": "cd tools/cli-engine && yarn run build",
"build:monaco-worker": "cd packages/monaco && yarn run build:worker",
"build:webview-prebuilt": "yarn workspace @opensumi/ide-webview bundle-webview",
"watch:ext-host": "cd packages/extension && yarn run watch:ext-host",
"watch:worker-host": "cd packages/extension && yarn run watch:worker",
"watch": "yarn run rebuild:node && cross-env NODE_ENV=production tsx ./scripts/watch",
Expand Down Expand Up @@ -95,6 +96,7 @@
"cross-env": "^7.0.3",
"debug": "^4.3.2",
"depcheck": "^1.4.7",
"esbuild": "^0.24.0",
"eslint": "^8.9.0",
"eslint-config-prettier": "^8.4.0",
"eslint-import-resolver-typescript": "^2.5.0",
Expand Down
5 changes: 5 additions & 0 deletions packages/core-browser/src/react-providers/config-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@ export interface AppConfig {
* 默认值:`http://${deviceIp}:${port}/webview`,
*/
webviewEndpoint?: string;
/**
* if you don't want to use the webviewEndpoint, you can use the built-in webview.
* webview content will be loaded by `iframe.srcdoc`.
*/
useBuiltinWebview?: boolean;
/**
* Worker 插件的默认启动路径
*/
Expand Down
4 changes: 1 addition & 3 deletions packages/startup/entry/web/render-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,6 @@ export async function renderApp(opts: IClientAppOpts) {

opts.extWorkerHost = opts.extWorkerHost || process.env.EXTENSION_WORKER_HOST;

const anotherHostName = process.env.WEBVIEW_HOST || defaultHost;
opts.webviewEndpoint = opts.webviewEndpoint || `http://${anotherHostName}:8899`;

opts.editorBackgroundImage =
'https://img.alicdn.com/imgextra/i2/O1CN01dqjQei1tpbj9z9VPH_!!6000000005951-55-tps-87-78.svg';

Expand Down Expand Up @@ -102,6 +99,7 @@ export const getDefaultClientAppOpts = ({
},
useCdnIcon: true,
useExperimentalShadowDom: true,
useBuiltinWebview: true,
defaultPreferences: {
'general.language': defaultLanguage,
'general.theme': 'opensumi-design-dark-theme',
Expand Down
5 changes: 2 additions & 3 deletions packages/startup/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,8 @@
"scripts": {
"prepublishOnly": "yarn run build",
"start:lite": "webpack-dev-server --config ./webpack.lite.config.js",
"start": "run-p start:client:open start:server start:webview",
"start:e2e": "run-p start:client:e2e start:server start:webview",
"start:webview": "webpack-dev-server --config ./webpack.webview.js",
"start": "run-p start:client:open start:server",
"start:e2e": "run-p start:client:e2e start:server",
"start:client": "webpack-dev-server --config ./webpack.config.js",
"start:server": "cross-env IS_DEV=1 NODE_ENV=development EXT_MODE=js KTLOG_SHOW_DEBUG=1 nodemon --inspect=9999 ./entry/web/server.ts",
"start:no-inspect:client": "yarn start:client",
Expand Down
4 changes: 2 additions & 2 deletions packages/webview/__tests__/webview/webview.channel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ mockElectronRenderer();
import { MockedElectronIpcRenderer } from '@opensumi/ide-core-common/lib/mocks/electron/ipcRenderer';

import { ElectronWebviewChannel } from '../../src/electron-webview/host-channel';
import { WebIframeChannel } from '../../src/webview-host/web-preload';
import { WebIframeChannel, getIdFromSearch } from '../../src/webview-host/web-iframe-channel';
import { WebviewPanelManager } from '../../src/webview-host/webview-manager';

const { JSDOM } = require('jsdom');
Expand All @@ -31,7 +31,7 @@ describe('electron webview test', () => {
});

describe('web iframe webview test', () => {
const manager = new WebviewPanelManager(new WebIframeChannel());
const manager = new WebviewPanelManager(new WebIframeChannel(getIdFromSearch));

it.skip('iframe webview test', () => {
(manager as any).init();
Expand Down
3 changes: 2 additions & 1 deletion packages/webview/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"typings": "lib/index.d.ts",
"scripts": {
"prepublishOnly": "yarn run build",
"build": "tsc --build ../../configs/ts/references/tsconfig.webview.json"
"build": "tsc --build ../../configs/ts/references/tsconfig.webview.json",
"bundle-webview": "node ./scripts/bundle-webview.mjs"
},
"repository": {
"type": "git",
Expand Down
69 changes: 69 additions & 0 deletions packages/webview/scripts/bundle-webview.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/* eslint-disable no-console */
import { mkdir, writeFile } from 'fs/promises';

import debug from 'debug';
import * as esbuild from 'esbuild';

const log = debug('webview:bundle-webview');

const result = await esbuild.build({
entryPoints: ['src/webview-host/web-preload-builtin.ts'],
sourcemap: false,
write: false,
bundle: true,
minify: true,
});

log(
'build result',
result.outputFiles.map((v) => ({
path: v.path,
length: v.text.length,
})),
);

const output = result.outputFiles[0].text;

const htmlWithScript = /* html */ `<!DOCTYPE html>
<html lang="en" style="width: 100%; height: 100%">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Webview Panel Container</title>
</head>
<body style="margin: 0; overflow: hidden; width: 100%; height: 100%"></body>
<script>
window.channelId = {{channelId}};
</script>
<script>
${output}
</script>
</html>
`;

const toWrite = /* javascript */ `
/* This file is generated by scripts/bundle-webview.mjs */
/* eslint-disable */
/* prettier-ignore */
const htmlContent = ${JSON.stringify(htmlWithScript)};
export const createHTML = (channelId: string) => {
return htmlContent.replace('{{channelId}}', JSON.stringify(channelId));
};
`.trimStart();

await writeFile('src/browser/iframe/prebuilt.ts', toWrite);

console.log('Successfully bundled webview, write to src/browser/iframe/prebuilt.ts');

if (process.env.DEBUG) {
try {
await mkdir('lib/prebuilt', { recursive: true });
await writeFile('lib/prebuilt/output.js', output);
await writeFile('lib/prebuilt/webview.html', htmlWithScript);
} catch (error) {
console.log('===== ~ error:', error);
}
}
12 changes: 11 additions & 1 deletion packages/webview/src/browser/iframe-webview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Autowired, Injectable } from '@opensumi/di';
import { AppConfig, Disposable, DomListener, IDisposable, URI, getDebugLogger } from '@opensumi/ide-core-browser';

import { AbstractWebviewPanel } from './abstract-webview';
import { createHTML } from './iframe/prebuilt';
import { IWebview, IWebviewContentOptions } from './types';

@Injectable({ multiple: true })
Expand All @@ -21,6 +22,9 @@ export class IFrameWebviewPanel extends AbstractWebviewPanel implements IWebview
super(id, options);

this.iframe = document.createElement('iframe');
this.iframe.name = `webview-${this.id}`;
this.iframe.id = `webview-${this.id}`;

this.iframe.setAttribute('allow', 'autoplay');

const sandboxRules = new Set(['allow-same-origin', 'allow-scripts']);
Expand All @@ -29,7 +33,13 @@ export class IFrameWebviewPanel extends AbstractWebviewPanel implements IWebview
sandboxRules.add('allow-forms');
}
this.iframe.setAttribute('sandbox', Array.from(sandboxRules).join(' '));
this.iframe.setAttribute('src', `${this.config.webviewEndpoint}/index.html?id=${this.id}`);

if (this.config.useBuiltinWebview || !this.config.webviewEndpoint) {
this.iframe.setAttribute('srcdoc', createHTML(this.id));
} else {
this.iframe.setAttribute('src', `${this.config.webviewEndpoint}/index.html?id=${this.id}`);
}

this.iframe.style.border = 'none';
this.iframe.style.width = '100%';
this.iframe.style.position = 'absolute';
Expand Down
8 changes: 8 additions & 0 deletions packages/webview/src/browser/iframe/prebuilt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/* This file is generated by scripts/bundle-webview.mjs */
/* eslint-disable */
/* prettier-ignore */
const htmlContent = "<!DOCTYPE html>\n<html lang=\"en\" style=\"width: 100%; height: 100%\">\n <head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <meta http-equiv=\"X-UA-Compatible\" content=\"ie=edge\" />\n <title>Webview Panel Container</title>\n </head>\n\n <body style=\"margin: 0; overflow: hidden; width: 100%; height: 100%\"></body>\n <script>\n window.channelId = {{channelId}};\n </script>\n <script>\n \"use strict\";(()=>{var u=class{constructor(e){this.getId=e;this.handlers=new Map;this.fakeLoad=!1;this.isInDevelopmentMode=!1;window.addEventListener(\"message\",t=>{if(t.data&&(t.data.command===\"onmessage\"||t.data.command===\"do-update-state\")){this.postMessage(t.data.command,t.data.data);return}let n=t.data.channel,o=this.handlers.get(n);o?o(t,t.data.data):console.log(\"no handler for \",t)}),this.ready=new Promise(t=>{t()}),this.onMessage(\"devtools-opened\",()=>{this.isInDevelopmentMode=!0})}get id(){return this._id||(this._id=this.getId()??\"\"),this._id}get inDev(){return this.isInDevelopmentMode}postMessage(e,t){window.parent!==window&&window.parent.postMessage({target:this.id,channel:e,data:t},\"*\")}onMessage(e,t){this.handlers.set(e,t)}onIframeLoaded(e){}onKeydown(e){e.key===\"s\"&&(e.metaKey||e.ctrlKey)&&e.preventDefault()}};var y=`body {\n background-color: var(--vscode-editor-background);\n color: var(--vscode-editor-foreground);\n font-family: var(--vscode-font-family);\n font-weight: var(--vscode-font-weight);\n font-size: var(--vscode-font-size);\n margin: 0;\n padding: 0 20px;\n}\n\nimg {\n max-width: 100%;\n max-height: 100%;\n}\n\na {\n color: var(--vscode-textLink-foreground);\n}\n\na:hover {\n color: var(--vscode-textLink-activeForeground);\n}\n\na:focus,\ninput:focus,\nselect:focus,\ntextarea:focus {\n outline: 1px solid -webkit-focus-ring-color;\n outline-offset: -1px;\n}\n\ncode {\n color: var(--vscode-textPreformat-foreground);\n}\n\nblockquote {\n background: var(--vscode-textBlockQuote-background);\n border-color: var(--vscode-textBlockQuote-border);\n}\n\n::-webkit-scrollbar {\n width: 10px;\n height: 10px;\n}\n\n::-webkit-scrollbar-thumb {\n background-color: var(--vscode-scrollbarSlider-background);\n}\n::-webkit-scrollbar-thumb:hover {\n background-color: var(--vscode-scrollbarSlider-hoverBackground);\n}\n::-webkit-scrollbar-thumb:active {\n background-color: var(--vscode-scrollbarSlider-activeBackground);\n}\n::-webkit-scrollbar-corner {\n background: transparent;\n}`;function M(l){return(l+\"\").replace(/[\\\\\"']/g,\"\\\\$&\").replace(/\\u0000/g,\"\\\\0\")}function b(l){return`\n const acquireVsCodeApi = (function() {\n const originalPostMessage = window.parent.postMessage.bind(window.parent);\n const targetOrigin = '*';\n let acquired = false;\n\n let state = ${l?`JSON.parse(\"${M(JSON.stringify(l))}\")`:void 0};\n\n return () => {\n if (acquired) {\n throw new Error('An instance of the VS Code API has already been acquired');\n }\n acquired = true;\n return Object.freeze({\n postMessage: function(msg) {\n return originalPostMessage({ command: 'onmessage', data: msg }, targetOrigin);\n },\n setState: function(newState) {\n state = newState;\n originalPostMessage({ command: 'do-update-state', data: JSON.parse(JSON.stringify(newState)) }, targetOrigin);\n return newState;\n },\n getState: function() {\n return state;\n }\n });\n };\n })();\n delete window.parent;\n delete window.top;\n delete window.frameElement;\n window.acquireVsCodeApi = acquireVsCodeApi;\n `}var m=class{constructor(e){this.channel=e;this.activeTheme=\"default\";this.isHandlingScroll=!1;this.updateId=0;this.firstLoad=!0;this.pendingMessages=[];document.addEventListener(\"DOMContentLoaded\",this.init.bind(this))}get ID(){return this.channel.id}init(){document.body&&(this.channel.onMessage(\"styles\",(e,t)=>{this.styles=t.styles,this.activeTheme=t.activeTheme;let n=this.getActiveFrame();n&&n.contentDocument&&this.applyStyles(n.contentDocument,n.contentDocument.body)}),this.channel.onMessage(\"focus\",()=>{let e=this.getActiveFrame();e&&e.contentWindow&&e.contentWindow.focus()}),this.channel.onMessage(\"content\",async(e,t)=>this.setContent(t)),this.channel.onMessage(\"message\",(e,t)=>{if(!this.getPendingFrame()){let o=this.getActiveFrame();if(o){o.contentWindow?.postMessage(t,\"*\");return}}this.pendingMessages.push(t)}),this.trackFocus({onFocus:()=>this.channel.postMessage(\"did-focus\"),onBlur:()=>this.channel.postMessage(\"did-blur\")}),this.channel.postMessage(\"webview-ready\",{}))}async setContent(e){let t=++this.updateId;if(await this.channel.ready,t!==this.updateId)return;let n=e.options,o=this.toContentHtml(e),d=this.getActiveFrame(),c=this.firstLoad,f;if(this.firstLoad)this.firstLoad=!1,f=(s,a)=>{isNaN(this.initialScrollProgress)||a.scrollY===0&&a.scroll(0,s.clientHeight*this.initialScrollProgress)};else{let s=d&&d.contentDocument&&d.contentDocument.body?d.contentWindow?.scrollY:0;f=(a,r)=>{r.scrollY===0&&r.scroll(0,s)}}let g=this.getPendingFrame();g&&(g.setAttribute(\"id\",\"\"),document.body.removeChild(g)),c||(this.pendingMessages=[]);let i=document.createElement(\"iframe\");i.setAttribute(\"id\",\"pending-frame\"),i.setAttribute(\"frameborder\",\"0\"),i.setAttribute(\"allow\",\"autoplay; clipboard-read; clipboard-write;\");let h=new Set([\"allow-same-origin\",\"allow-pointer-lock\"]);n.allowScripts&&(h.add(\"allow-scripts\"),h.add(\"allow-downloads\")),n.allowForms&&h.add(\"allow-forms\"),i.setAttribute(\"sandbox\",Array.from(h).join(\" \")),this.channel.fakeLoad&&(i.src=`./fake.html?id=${this.ID}`),i.style.cssText=\"display: block; margin: 0; overflow: hidden; position: absolute; width: 100%; height: 100%; visibility: hidden\",document.body.appendChild(i),this.channel.fakeLoad||i.contentDocument?.open(),i.contentWindow?.addEventListener(\"keydown\",this.handleInnerKeydown.bind(this)),i.contentWindow?.addEventListener(\"DOMContentLoaded\",s=>{this.channel.fakeLoad&&(i.contentDocument?.open(),i.contentDocument?.write(o),i.contentDocument?.close(),v(i));let a=s.target?s.target:void 0;a&&this.applyStyles(a,a.body)});let p=(s,a)=>{s&&s.body&&f(s.body,a);let r=this.getPendingFrame();if(r&&r.contentDocument&&r.contentDocument===s){let w=this.getActiveFrame();w&&document.body.removeChild(w),this.applyStyles(r.contentDocument,r.contentDocument.body),r.setAttribute(\"id\",\"active-frame\"),r.style.visibility=\"visible\",this.channel.focusIframeOnCreate&&r.contentWindow?.focus(),a.addEventListener(\"scroll\",this.handleInnerScroll.bind(this)),this.pendingMessages.forEach(k=>{a.postMessage(k,\"*\")}),this.pendingMessages=[]}},v=s=>{clearTimeout(this.loadTimeout),this.loadTimeout=void 0,this.loadTimeout=setTimeout(()=>{clearTimeout(this.loadTimeout),this.loadTimeout=void 0,p(s.contentDocument,s.contentWindow)},1e3);let a=this;s.contentWindow.addEventListener(\"load\",function(r){a.loadTimeout&&(clearTimeout(a.loadTimeout),a.loadTimeout=void 0,p(r.target,this))}),s.contentWindow.addEventListener(\"click\",this.handleInnerClick.bind(this)),this.channel.onIframeLoaded&&this.channel.onIframeLoaded(s)};this.channel.fakeLoad||v(i),this.channel.fakeLoad||(i.contentDocument?.write(o),i.contentDocument?.close()),this.channel.postMessage(\"did-set-content\",void 0)}trackFocus({onFocus:e,onBlur:t}){let o=document.hasFocus();setInterval(()=>{let d=document.hasFocus();d!==o&&(o=d,d?e():t())},50)}getActiveFrame(){return document.getElementById(\"active-frame\")}getPendingFrame(){return document.getElementById(\"pending-frame\")}get defaultCssRules(){return y}applyStyles(e,t){if(e&&(t&&(t.classList.remove(\"vscode-light\",\"vscode-dark\",\"vscode-high-contrast\"),t.classList.add(this.activeTheme)),this.styles))for(let n of Object.keys(this.styles))e.documentElement.style.setProperty(`--${n}`,this.styles[n])}handleInnerClick(e){if(!e||!e.view||!e.view.document)return;let t=e.view.document.getElementsByTagName(\"base\")[0],n=e.target;for(;n;){if(n.tagName&&n.tagName.toLowerCase()===\"a\"&&n.href){if(n.getAttribute(\"href\")===\"#\")e.view.scrollTo(0,0);else if(n.hash&&(n.getAttribute(\"href\")===n.hash||t&&n.href.indexOf(t.href)>=0)){let o=e.view.document.getElementById(n.hash.substr(1,n.hash.length-1));o&&o.scrollIntoView()}else this.channel.postMessage(\"did-click-link\",n.href.baseVal||n.href);e.preventDefault();break}n=n.parentNode}}handleInnerKeydown(e){this.channel.postMessage(\"did-keydown\",{key:e.key,keyCode:e.keyCode,code:e.code,shiftKey:e.shiftKey,altKey:e.altKey,ctrlKey:e.ctrlKey,metaKey:e.metaKey,repeat:e.repeat}),this.channel.onKeydown&&this.channel.onKeydown(e)}handleInnerScroll(e){if(!e.target||!e.target.body||this.isHandlingScroll)return;let t=e.currentTarget.scrollY/e.target.body.clientHeight;isNaN(t)||(this.isHandlingScroll=!0,window.requestAnimationFrame(()=>{try{this.channel.postMessage(\"did-scroll\",t)}catch{}this.isHandlingScroll=!1}))}toContentHtml(e){let t=e.options,n=e.contents,o=new DOMParser().parseFromString(n,\"text/html\");if(o.querySelectorAll(\"a\").forEach(c=>{c.title||(c.title=c.getAttribute(\"href\"))}),t.allowScripts){let c=o.createElement(\"script\");c.textContent=b(e.state),o.head.prepend(c)}let d=o.createElement(\"style\");return d.id=\"_defaultStyles\",d.innerHTML=this.defaultCssRules,o.head.prepend(d),this.applyStyles(o,o.body),`<!DOCTYPE html>\n`+o.documentElement.outerHTML}};new m(new u(()=>window.channelId));})();\n\n </script>\n</html>\n";

export const createHTML = (channelId: string) => {
return htmlContent.replace('{{channelId}}', JSON.stringify(channelId));
};
2 changes: 1 addition & 1 deletion packages/webview/src/browser/webview.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ export class WebviewServiceImpl implements IWebviewService {

getWebviewThemeData(theme: ITheme): IWebviewThemeData {
const editorFontFamily = this.editorPreferences['editor.fontFamily'];
const editorFontWeight = this.editorPreferences['editor.fontFamily'];
const editorFontWeight = this.editorPreferences['editor.fontWeight'];
const editorFontSize = this.editorPreferences['editor.fontSize'];

const exportedColors = getColorRegistry()
Expand Down
5 changes: 5 additions & 0 deletions packages/webview/src/electron-webview/host-channel.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ipcRenderer } from 'electron';

import { IWebviewChannel } from '../webview-host/common';
import { getIdFromSearch } from '../webview-host/web-iframe-channel';
import { WebviewPanelManager } from '../webview-host/webview-manager';

export class ElectronWebviewChannel implements IWebviewChannel {
Expand All @@ -11,6 +12,10 @@ export class ElectronWebviewChannel implements IWebviewChannel {

public isInDevelopmentMode = false;

get id() {
return getIdFromSearch();
}

constructor() {
window.addEventListener('message', (e) => {
if (e.data && (e.data.command === 'onmessage' || e.data.command === 'do-update-state')) {
Expand Down
1 change: 1 addition & 0 deletions packages/webview/src/webview-host/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export interface IWebviewChannel {
ready?: Promise<void>;
onIframeLoaded?: (iframe: HTMLIFrameElement) => void;
fakeLoad: boolean;
id: string;
onKeydown?: (event: KeyboardEvent) => void;
}

Expand Down
Loading

0 comments on commit 6643c17

Please sign in to comment.