diff --git a/.gitignore b/.gitignore index 8697d81..02e104b 100644 --- a/.gitignore +++ b/.gitignore @@ -103,4 +103,7 @@ dist # TernJS port file .tern-port -.vscode-test-web \ No newline at end of file +.vscode-test-web + +resources/sim.js +resources/sim.js.map \ No newline at end of file diff --git a/package.json b/package.json index 171c995..fa3f43e 100644 --- a/package.json +++ b/package.json @@ -332,13 +332,14 @@ "test": "vscode-test-web --browserType=chromium --extensionDevelopmentPath=. --extensionTestsPath=dist/web/test/suite/index.js", "pretest": "yarn run compile-web", "vscode:prepublish": "yarn run package-web", - "compile-web": "webpack", - "watch-web": "webpack --watch", - "package-web": "webpack --mode production --devtool hidden-source-map", + "compile-web": "yarn run simulator && webpack", + "watch-web": "yarn run simulator && webpack --watch", + "package-web": "yarn run simulator && webpack --mode production --devtool hidden-source-map", "lint": "eslint src --ext ts", - "run-in-browser": "vscode-test-web --browserType=chromium --extensionDevelopmentPath=.", + "run-in-browser": "yarn run simulator && vscode-test-web --browserType=chromium --extensionDevelopmentPath=.", "generate-l10n": "vscode-l10n-dev export --outDir ./l10n ./src", - "generate-l10n-ploc": "vscode-l10n-dev generate-pseudo -o ./l10n ./l10n/bundle.l10n.json ./package.nls.json" + "generate-l10n-ploc": "vscode-l10n-dev generate-pseudo -o ./l10n ./l10n/bundle.l10n.json ./package.nls.json", + "simulator": "cd src/sim && webpack" }, "devDependencies": { "@types/mocha": "^10.0.0", @@ -362,7 +363,7 @@ "@types/path-browserify": "^1.0.0", "@vscode/extension-telemetry": "^0.7.5", "makecode-browser": "^1.3.3", - "makecode-core": "^1.7.2", + "makecode-core": "1.7.8", "path-browserify": "^1.0.1" } } diff --git a/src/localtypings/simulatorExtensionMessages.d.ts b/src/localtypings/simulatorExtensionMessages.d.ts new file mode 100644 index 0000000..d1c6f97 --- /dev/null +++ b/src/localtypings/simulatorExtensionMessages.d.ts @@ -0,0 +1,28 @@ +interface VSCodeAPI { + postMessage(data: any): void; +} + +interface BaseMessage { + type: "simulator-extension" + src: "simulator" | "extension"; + action: string; + id?: string; +} + +interface VSCodeResponse extends BaseMessage { + success: boolean; + id: string; +} + +interface TargetConfigMessage extends BaseMessage { + action: "targetConfig"; +} + +interface TargetConfigResponse extends VSCodeResponse { + action: "targetConfig"; + config: any; + webConfig: any; +} + +type SimulatorExtensionMessage = TargetConfigMessage; +type SimulatorExtensionResponse = TargetConfigResponse | VSCodeResponse; \ No newline at end of file diff --git a/src/sim/frames.ts b/src/sim/frames.ts new file mode 100644 index 0000000..51aa80f --- /dev/null +++ b/src/sim/frames.ts @@ -0,0 +1,140 @@ +import { getTargetConfigAsync } from "./service"; + +const FRAME_DATA_MESSAGE_CHANNEL = "messagechannel" +const FRAME_ASPECT_RATIO = "aspectratio" +const MESSAGE_SOURCE = "pxtdriver" +const PERMANENT = "permanent" + + +let simFrames: {[index: string]: HTMLIFrameElement} = {}; +let _targetConfig: Promise; + + +function nextId() { + return crypto.randomUUID(); +} + +let pendingFrames: {[index: string]: Promise} = {}; + +export function handleMessagePacket(message: any, source?: Window) { + if (message.type === "messagepacket") { + const channel = message.channel as string; + + if (!pendingFrames[channel]) { + pendingFrames[channel] = (async () => { + const { config, webConfig } = await targetConfigAsync(); + const simInfo = config.packages.approvedRepoLib[channel]; + + if (simInfo.simx) { + startSimulatorExtension( + getSimxUrl(webConfig, channel, simInfo.simx.index), + true, + undefined, + channel + ); + } + })(); + } + + pendingFrames[channel].then(() => { + for (const frame of Object.keys(simFrames)) { + const contentWindow = simFrames[frame].contentWindow; + + if (contentWindow && contentWindow !== source) { + simFrames[frame].contentWindow?.postMessage(message, "*"); + } + } + }) + + const simFrame = getSimFrame(); + + if (simFrame.contentWindow && simFrame.contentWindow !== source) { + simFrame.contentWindow.postMessage(message, "*") + } + } +} + +function getSimFrame() { + return document.getElementById("simframe") as HTMLIFrameElement; +} + + +function getSimxUrl(webConfig: any, repo: string, index = "index.html") { + const simUrl = new URL(webConfig.simUrl); + + // Ensure we preserve upload target path (/app/---simulator) + const simPath = simUrl.pathname.replace(/---?.*/, ""); + // Construct the path. The "-" element delineates the extension key from the resource name. + const simxPath = [simPath, "simx", repo, "-", index].join("/"); + // Create the fully-qualified URL, preserving the origin by removing all leading slashes + return new URL(simxPath.replace(/^\/+/, ""), simUrl.origin).toString(); +} + + +function createFrame(url: string): HTMLDivElement { + const wrapper = document.createElement("div") as HTMLDivElement; + wrapper.className = `simframe ui embed`; + + const frame = document.createElement('iframe') as HTMLIFrameElement; + frame.id = 'sim-frame-' + nextId() + frame.title = "Simulator"; + frame.allowFullscreen = true; + frame.setAttribute('allow', 'autoplay;microphone'); + frame.setAttribute('sandbox', 'allow-same-origin allow-scripts'); + frame.className = 'no-select'; + + let furl = url; + furl += '#' + frame.id; + + frame.src = furl; + frame.frameBorder = "0"; + // frame.dataset['runid'] = this.runId; + frame.dataset['origin'] = new URL(furl).origin || "*"; + frame.dataset['loading'] = "true"; + + wrapper.appendChild(frame); + + const i = document.createElement("i"); + i.className = "videoplay xicon icon"; + i.style.display = "none"; + wrapper.appendChild(i); + + const l = document.createElement("div"); + l.className = "ui active loader"; + i.style.display = "none"; + wrapper.appendChild(l); + + return wrapper; +} + +function startSimulatorExtension(url: string, permanent: boolean, aspectRatio?: number, messageChannel?: string) { + const root = document.getElementById("root"); + if (root) { + root.classList.add("simx"); + } + + aspectRatio = aspectRatio || 1.22; + let wrapper = createFrame(url); + getContainer().appendChild(wrapper); + const messageFrame = wrapper.firstElementChild as HTMLIFrameElement; + messageFrame.dataset[FRAME_DATA_MESSAGE_CHANNEL] = messageChannel; + messageFrame.dataset[FRAME_ASPECT_RATIO] = aspectRatio + ""; + wrapper.classList.add("simmsg"); + if (permanent) { + messageFrame.dataset[PERMANENT] = "true"; + } + + simFrames[messageChannel!] = messageFrame; +} + +function getContainer() { + return document.getElementById("simulator-extension-frames") as HTMLDivElement; +} + +async function targetConfigAsync() { + if (!_targetConfig) { + _targetConfig = getTargetConfigAsync(); + } + + return _targetConfig; +} \ No newline at end of file diff --git a/src/sim/index.ts b/src/sim/index.ts new file mode 100644 index 0000000..5db3503 --- /dev/null +++ b/src/sim/index.ts @@ -0,0 +1,26 @@ +import { handleMessagePacket } from "./frames"; +import { initService } from "./service"; + +const acquireApi = (window as any).acquireVsCodeApi; +const vscode = acquireApi(); + +(window as any).acquireVsCodeApi = () => vscode; + +window.addEventListener("message", function (m) { + handleMessagePacket(m.data, m.source as Window); +}); + +document.addEventListener("DOMContentLoaded", function (event) { + const fs = document.getElementById("fullscreen"); + if (fs) { + fs.remove(); + } + + const simFrame = document.getElementById("simframe") as HTMLIFrameElement; + + const framesContainer = document.createElement("div"); + framesContainer.id = "simulator-extension-frames"; + simFrame.insertAdjacentElement("afterend", framesContainer); +}); + +initService(vscode); \ No newline at end of file diff --git a/src/sim/service.ts b/src/sim/service.ts new file mode 100644 index 0000000..7c9c580 --- /dev/null +++ b/src/sim/service.ts @@ -0,0 +1,50 @@ +/// + +let vscode: VSCodeAPI; +const pendingMessages: {[index: string]: (resp: SimulatorExtensionResponse) => void} = {}; + +export async function getTargetConfigAsync() { + const resp = await sendMessageAsync({ + type: "simulator-extension", + src: "simulator", + action: "targetConfig" + }) as TargetConfigResponse; + + return resp; +} + +function sendMessageAsync(message: SimulatorExtensionMessage): Promise { + return new Promise((resolve, reject) => { + const toSend: BaseMessage = { + ...message, + id: crypto.randomUUID() + }; + + pendingMessages[toSend.id!] = resp => { + if (resp.success) { + resolve(resp); + } + else { + reject(resp); + } + }; + + vscode.postMessage(toSend); + }); +} + +export function initService(api: VSCodeAPI) { + vscode = api; + + window.addEventListener("message", function (m) { + if (m.data?.type === "simulator-extension") { + const response = m.data as SimulatorExtensionResponse; + + const handler = pendingMessages[response.id]; + if (handler) { + delete pendingMessages[response.id]; + handler(response); + } + } + }); +} \ No newline at end of file diff --git a/src/sim/tsconfig.json b/src/sim/tsconfig.json new file mode 100644 index 0000000..f66f00d --- /dev/null +++ b/src/sim/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2020", + "outDir": "../../dist/sim", + "lib": [ + "ES2020", "DOM" + ], + "sourceMap": true, + "rootDir": ".", + "strict": true /* enable all strict type-checking options */ + /* Additional Checks */ + // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + // "noUnusedParameters": true, /* Report errors on unused parameters. */ + } +} diff --git a/src/sim/webpack.config.js b/src/sim/webpack.config.js new file mode 100644 index 0000000..6b2a54c --- /dev/null +++ b/src/sim/webpack.config.js @@ -0,0 +1,76 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//@ts-check +"use strict"; + +//@ts-check +/** @typedef {import("webpack").Configuration} WebpackConfig **/ + +const path = require("path"); +const webpack = require("webpack"); + +/** @type WebpackConfig */ +const webExtensionConfig = { + mode: "none", // this leaves the source code as close as possible to the original (when packaging we set this to "production") + target: "web", // extensions run in a webworker context + entry: { + "extension": "./index.ts", + }, + output: { + filename: "sim.js", + path: path.join(__dirname, "../../resources"), + libraryTarget: "commonjs", + devtoolModuleFilenameTemplate: "../../[resource-path]" + }, + resolve: { + mainFields: ["browser", "module", "main"], // look for `browser` entry point in imported node modules + extensions: [".ts", ".js"], // support ts-files and js-files + alias: { + // provides alternate implementation for node module and source files + }, + fallback: { + // Webpack 5 no longer polyfills Node.js core modules automatically. + // see https://webpack.js.org/configuration/resolve/#resolvefallback + // for the list of Node.js core module polyfills. + "assert": require.resolve("assert"), + "path": require.resolve("path-browserify") + } + }, + module: { + rules: [{ + test: /\.ts$/, + exclude: /node_modules/, + use: [{ + loader: "ts-loader" + }] + }, + { + // Make sure our marketplace icon is copied to final output + test: /\.(jpe?g|gif|png|svg)$/, + loader: "file" + }] + }, + plugins: [ + new webpack.optimize.LimitChunkCountPlugin({ + maxChunks: 1 // disable chunks by default since web extensions must be a single bundle + }), + new webpack.ProvidePlugin({ + process: "process/browser", // provide a shim for the global `process` variable + }), + ], + externals: { + "vscode": "commonjs vscode", // ignored because it doesn"t exist + }, + performance: { + hints: false + }, + devtool: "nosources-source-map", // create a source map that points to the original source file + infrastructureLogging: { + level: "log", // enables logging required for problem matchers + }, +}; + +module.exports = [ webExtensionConfig ]; \ No newline at end of file diff --git a/src/web/makecodeOperations.ts b/src/web/makecodeOperations.ts index 6965e15..58a91c1 100644 --- a/src/web/makecodeOperations.ts +++ b/src/web/makecodeOperations.ts @@ -72,6 +72,10 @@ export function getSimHtmlAsync(folder: vscode.WorkspaceFolder, cancellationToke return enqueueOperationAsync(folder, () => cmd.getSimHTML({}), cancellationToken); } +export function getWebConfigAsync(folder: vscode.WorkspaceFolder, cancellationToken?: vscode.CancellationToken) { + return enqueueOperationAsync(folder, () => cmd.getWebConfig({}), cancellationToken); +} + export function getAssetEditorHtmlAsync(folder: vscode.WorkspaceFolder, cancellationToken?: vscode.CancellationToken) { return enqueueOperationAsync(folder, () => cmd.getAssetEditorHTML({}), cancellationToken); } diff --git a/src/web/simulator.ts b/src/web/simulator.ts index 60b4348..7c5414b 100644 --- a/src/web/simulator.ts +++ b/src/web/simulator.ts @@ -1,9 +1,11 @@ +/// + import * as vscode from "vscode"; import { simloaderFiles } from "makecode-core/built/simloaderfiles"; import { activeWorkspace, existsAsync, readFileAsync } from "./host"; import { simulateCommand } from "./extension"; -import { getSimHtmlAsync } from "./makecodeOperations"; +import { getSimHtmlAsync, getTargetConfigAsync, getWebConfigAsync } from "./makecodeOperations"; let extensionContext: vscode.ExtensionContext; @@ -79,7 +81,7 @@ export class Simulator { async simulateAsync(binaryJS: string) { this.binaryJS = binaryJS; this.panel.webview.html = ""; - const simulatorHTML = await getSimLoaderHtmlAsync(); + const simulatorHTML = await getSimLoaderHtmlAsync(this.panel.webview); this.simHtml = await getSimHtmlAsync(activeWorkspace()); if (this.simState == null) { this.simState = await extensionContext.workspaceState.get("simstate", {}); @@ -124,6 +126,10 @@ export class Simulator { Simulator.simconsole.show(false); this.stopSimulator(); } + break; + case "simulator-extension": + this.handleSimExtensionMessage(message); + break; } } @@ -132,9 +138,42 @@ export class Simulator { this.panel.webview.postMessage(msg); } + protected postResponse(msg: SimulatorExtensionResponse) { + this.panel.webview.postMessage(msg); + } + addDisposable(d: vscode.Disposable) { this.disposables.push(d); } + + protected async handleSimExtensionMessage(message: SimulatorExtensionMessage) { + switch (message.action) { + case "targetConfig": + await this.handleTargetConfigRequestAsync(message); + break; + } + } + + protected async handleTargetConfigRequestAsync(message: TargetConfigMessage) { + try { + const config = await getTargetConfigAsync(activeWorkspace()); + const webConfig = await getWebConfigAsync(activeWorkspace()); + this.postResponse({ + ...message, + id: message.id!, + success: true, + config, + webConfig + }); + } + catch (e) { + this.postResponse({ + ...message, + id: message.id!, + success: false, + }); + } + } } export class SimulatorSerializer implements vscode.WebviewPanelSerializer { @@ -145,17 +184,28 @@ export class SimulatorSerializer implements vscode.WebviewPanelSerializer { } } -const vscodeExtensionExtraLoaderJs = ` -window.addEventListener("DOMContentLoaded", () => { - const fs = document.getElementById("fullscreen"); - if (fs) { - fs.remove(); - } -}); -`; +const injectedCss = ` +#root.simx { + overflow-y: auto; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; +} + +#root.simx iframe { + position: relative; + height: unset; + min-height: 200px; + flex-grow: 1; +} +` -async function getSimLoaderHtmlAsync() { + + +async function getSimLoaderHtmlAsync(webview: vscode.Webview) { const index = simloaderFiles["index.html"]; const loaderJs = simloaderFiles["loader.js"]; let customJs = simloaderFiles["custom.js"]; @@ -168,6 +218,9 @@ async function getSimLoaderHtmlAsync() { customJs = await readFileAsync("assets/js/" + customPath, "utf8"); } + const pathURL = (s: string) => + webview.asWebviewUri(vscode.Uri.joinPath(extensionContext.extensionUri, "resources", s)).toString(); + // In order to avoid using a server, we inline the loader and custom js files return index.replace(/<\s*script\s+type="text\/javascript"\s+src="([^"]+)"\s*>\s*<\/\s*script\s*>/g, (substring, match) => { if (match === "loader.js") { @@ -175,9 +228,10 @@ async function getSimLoaderHtmlAsync() { - + + `; } else if (match === "custom.js") { diff --git a/tsconfig.json b/tsconfig.json index e2e6965..2cc7570 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,5 +13,8 @@ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ // "noUnusedParameters": true, /* Report errors on unused parameters. */ - } + }, + "exclude": [ + "./src/sim" + ] } diff --git a/webpack.config.js b/webpack.config.js index 1ed82c9..3aef69a 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -43,7 +43,10 @@ const webExtensionConfig = { module: { rules: [{ test: /\.ts$/, - exclude: /node_modules/, + exclude: [ + /node_modules/, + /(?:\/|\\)src(?:\/|\\)sim(?:\/|\\)/ + ], use: [{ loader: "ts-loader" }] diff --git a/yarn.lock b/yarn.lock index b15bbf7..5de3989 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2174,6 +2174,14 @@ makecode-browser@^1.3.3: dependencies: makecode-core "^1.7.2" +makecode-core@1.7.8: + version "1.7.8" + resolved "https://registry.yarnpkg.com/makecode-core/-/makecode-core-1.7.8.tgz#50acdf5f00c25bfc9a2612fa0f48807392cfc831" + integrity sha512-tlzYTFkjK/XhUVysGFdJU4YR+coP0z+szTY/w3KE6jspf2/631dXuKOzXNkAcQQVNuYLHbOaGVEBS+WzDTxR3g== + dependencies: + "@xmldom/xmldom" "^0.9.8" + chalk "^4.1.2" + makecode-core@^1.7.2: version "1.7.2" resolved "https://registry.yarnpkg.com/makecode-core/-/makecode-core-1.7.2.tgz#9f35ffa652115f3acf874e575f9758a1299fc3cb"