diff --git a/.vscode/launch.json b/.vscode/launch.json index 66da6e31724c..ccb8c126af03 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -52,7 +52,11 @@ "args": [ "serve", "--rebundle", - "--noauth" + "--noauth", + "--hostname", + "127.0.0.1", + "--backport", + "8080" ], "cwd": "${workspaceRoot}/../pxt-microbit", "runtimeExecutable": null, diff --git a/cli/cli.ts b/cli/cli.ts index 5ba851aea3bf..567f9620b939 100644 --- a/cli/cli.ts +++ b/cli/cli.ts @@ -2855,6 +2855,7 @@ export function serveAsync(parsed: commandParser.ParsedCommand) { browser: parsed.flags["browser"] as string, serial: !parsed.flags["noSerial"] && !globalConfig.noSerial, noauth: parsed.flags["noauth"] as boolean || false, + backport: parsed.flags["backport"] as number || 0, })) } @@ -6966,6 +6967,11 @@ ${pxt.crowdin.KEY_VARIABLE} - crowdin key noauth: { description: "disable localtoken-based authentication", aliases: ["na"], + }, + backport: { + description: "port where the locally running backend is listening.", + argument: "backport", + type: "number", } } }, serveAsync); diff --git a/cli/server.ts b/cli/server.ts index 123dd9d6c7b5..061083f414bb 100644 --- a/cli/server.ts +++ b/cli/server.ts @@ -803,6 +803,7 @@ export interface ServeOptions { wsPort?: number; serial?: boolean; noauth?: boolean; + backport?: number; } // can use http://localhost:3232/streams/nnngzlzxslfu for testing @@ -988,6 +989,11 @@ export function serveAsync(options: ServeOptions) { } } + // Strip /app/hash-sig from URL. + // This can happen when the locally running backend is serving an uploaded target, + // but has been configured to route simulator urls to port 3232. + req.url = req.url.replace(/^\/app\/[0-9a-f]{40}(?:-[0-9a-f]{10})?(.*)$/i, "$1"); + let uri = url.parse(req.url); let pathname = decodeURI(uri.pathname); const opts: pxt.Map = querystring.parse(url.parse(req.url).query); @@ -1142,6 +1148,30 @@ export function serveAsync(options: ServeOptions) { } } + if (elts[0] == "simx" && serveOptions.backport) { + // Proxy requests for simulator extensions to the locally running backend. + // Should only get here when the backend is running locally and configured to serve the simulator from the cli (via LOCAL_SIM_PORT setting). + const passthruOpts = { + hostname: uri.hostname, + port: serveOptions.backport, + path: uri.path, + method: req.method, + headers: req.headers + }; + + const passthruReq = http.request(passthruOpts, passthruRes => { + res.writeHead(passthruRes.statusCode, passthruRes.headers); + passthruRes.pipe(res); + }); + + passthruReq.on("error", e => { + console.error(`Error proxying request to port ${serveOptions.backport} .. ${e.message}`); + return error(500, e.message); + }); + + return req.pipe(passthruReq); + } + if (options.packaged) { let filename = path.resolve(path.join(packagedDir, pathname)) if (nodeutil.fileExistsSync(filename)) { @@ -1248,6 +1278,20 @@ export function serveAsync(options: ServeOptions) { } } + // Look for an .html file corresponding to `/---` + // Handles serving of `trg-.sim.local:/---simulator` + let match = /^\/?---?(.*)/.exec(pathname) + if (match && match[1]) { + const htmlPathname = `/${match[1]}.html` + for (let dir of dd) { + const filename = path.resolve(path.join(dir, htmlPathname)) + if (nodeutil.fileExistsSync(filename)) { + const html = expandHtml(fs.readFileSync(filename, "utf8"), htmlParams) + return sendHtml(html) + } + } + } + if (/simulator\.html/.test(pathname)) { // Special handling for missing simulator: redirect to the live sim res.writeHead(302, { location: `https://trg-${pxt.appTarget.id}.userpxt.io/---simulator` }); diff --git a/localtypings/pxtarget.d.ts b/localtypings/pxtarget.d.ts index aa6b88b1ca8e..1c8fd4b54e32 100644 --- a/localtypings/pxtarget.d.ts +++ b/localtypings/pxtarget.d.ts @@ -56,6 +56,19 @@ declare namespace pxt { // "acme-corp/pxt-widget": "min:v0.1.2" - auto-upgrade to that version // "acme-corp/pxt-widget": "dv:foo,bar" - add "disablesVariant": ["foo", "bar"] to pxt.json upgrades?: string[]; + // This repo's simulator extension configuration + simx?: SimulatorExtensionConfig; + } + + interface SimulatorExtensionConfig { + aspectRatio?: number; // Aspect ratio for the iframe. Default: 1.22. + permanent?: boolean; // If true, don't recycle the iframe between runs. Default: true. + devUrl?: string; // URL to load for local development. Pass `simxdev` on URL to enable. Default: undefined. + index?: string; // The path to the simulator extension's entry point within the repo. Default: "index.html". + // backend-only options + sha?: string; // The commit to checkout (must exist in the branch/ref). Required. + repo?: string; // Actual repo to load simulator extension from. Defaults to key of parent in `approvedRepoLib` map. + ref?: string; // The branch of the repo to sync. Default: "gh-pages". } interface ShareConfig { @@ -267,6 +280,7 @@ declare namespace pxt { keymap?: boolean; // when non-empty and autoRun is disabled, this code is run upon simulator first start // a map of allowed simulator channel to URL to handle specific control messages + // DEPRECATED. Use `simx` in targetconfig.json approvedRepoLib instead. messageSimulators?: pxt.Map<{ // the URL to load the simulator, $PARENT_ORIGIN$ will be replaced by the parent // origin to validate messages @@ -277,6 +291,9 @@ declare namespace pxt { // don't recycle the iframe between runs permanent?: boolean; }>; + // This is for testing new simulator extensions before adding them to targetconfig.json. + // DO NOT SHIP SIMULATOR EXTENSIONS HERE. Add them to targetconfig.json/approvedRepoLib instead. + testSimulatorExtensions?: pxt.Map; } interface TargetCompileService { diff --git a/pxtlib/browserutils.ts b/pxtlib/browserutils.ts index 3a1d3f783409..3780380664e8 100644 --- a/pxtlib/browserutils.ts +++ b/pxtlib/browserutils.ts @@ -149,7 +149,7 @@ namespace pxt.BrowserUtils { export function isLocalHost(ignoreFlags?: boolean): boolean { try { return typeof window !== "undefined" - && /^http:\/\/(localhost|127\.0\.0\.1|192\.168\.\d+\.\d+):\d+\//.test(window.location.href) + && /^http:\/\/(?:localhost|127\.0\.0\.1|192\.168\.\d{1,3}\.\d{1,3}|[a-zA-Z0-9.-]+\.local):\d+\/?/.test(window.location.href) && (ignoreFlags || !/nolocalhost=1/.test(window.location.href)) && !(pxt?.webConfig?.isStatic); } catch (e) { return false; } diff --git a/pxtsim/runtime.ts b/pxtsim/runtime.ts index e946965427e0..3a95257e847c 100644 --- a/pxtsim/runtime.ts +++ b/pxtsim/runtime.ts @@ -285,11 +285,13 @@ namespace pxsim { return isPxtElectron() || isIpcRenderer(); } + export function testLocalhost(url: string): boolean { + return /^http:\/\/(?:localhost|127\.0\.0\.1|192\.168\.\d{1,3}\.\d{1,3}|[a-zA-Z0-9.-]+\.local):\d+\/?/.test(url) && !/nolocalhost=1/.test(url); + } + export function isLocalHost(): boolean { try { - return typeof window !== "undefined" - && /^http:\/\/(localhost|127\.0\.0\.1):\d+\//.test(window.location.href) - && !/nolocalhost=1/.test(window.location.href); + return typeof window !== "undefined" && testLocalhost(window.location.href); } catch (e) { return false; } } diff --git a/pxtsim/simdriver.ts b/pxtsim/simdriver.ts index a2bc13b2b216..0f8710e2d0f9 100644 --- a/pxtsim/simdriver.ts +++ b/pxtsim/simdriver.ts @@ -21,13 +21,24 @@ namespace pxsim { nestedEditorSim?: boolean; parentOrigin?: string; mpRole?: string; // multiplayer role: "client", "server", or undefined + // `messageSimulators` is @DEPRECATED. Use `simulatorExtensions` instead. messageSimulators?: pxt.Map<{ url: string; localHostUrl?: string; aspectRatio?: number; permanent?: boolean; }>; - // needed when messageSimulators are used to provide to their frame + // Simulator extensions read from targetconfig.json's `approvedRepoLib` entry. + simulatorExtensions?: pxt.Map<{ + // Fields from pxt.SimulatorExtensionConfig + aspectRatio?: number; + permanent?: boolean; + index?: string; + devUrl?: string; + // Additional fields outside of pxt.SimulatorExtensionConfig + url?: string; // Computed URL + }>; + // needed when simulatorExtensions are used to provide to their frame userLanguage?: string; } @@ -120,16 +131,45 @@ namespace pxsim { this._allowedOrigins.push(this.getSimUrl().origin); - const messageSimulators = options?.messageSimulators - if (messageSimulators) { - Object.keys(messageSimulators) - .map(channel => messageSimulators[channel]) - .forEach(messageSimulator => { - this._allowedOrigins.push(new URL(messageSimulator.url).origin); - if (messageSimulator.localHostUrl) - this._allowedOrigins.push(new URL(messageSimulator.localHostUrl).origin); - }); - } + // Legacy support for message simulators + const messageSimulators = options?.messageSimulators || {}; + Object.keys(messageSimulators) + .map(channel => messageSimulators[channel]) + .forEach(messageSimulator => { + this._allowedOrigins.push(new URL(messageSimulator.url).origin); + if (messageSimulator.localHostUrl) + this._allowedOrigins.push(new URL(messageSimulator.localHostUrl).origin); + }); + + // Preprocess simulator extensions + const simXDevMode = U.isLocalHost() && /[?&]simxdev(?:[=&#]|$)/i.test(window.location.href); + Object.entries(options?.simulatorExtensions || {}).forEach(([key, simx]) => { + // Verify essential `simx` config was provided + if ( + !simx || + !simx.index || + !simx.aspectRatio || + simx.permanent === undefined + ) { + return; + } + // Compute the effective URL + if (simXDevMode && simx.devUrl) { + // Use the dev URL if the dev flag is set (and we're on localhost) + simx.url = new URL(simx.index, simx.devUrl).toString(); + } else { + const simUrl = this.getSimUrl(); + // 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", key, "-", simx.index].join("/"); + simx.url = new URL(simxPath, simUrl.origin).toString(); + } + + // Add the origin to the allowed origins + this._allowedOrigins.push(new URL(simx.url).origin); + }); + this._allowedOrigins = U.unique(this._allowedOrigins, f => f); } @@ -349,8 +389,43 @@ namespace pxsim { const messageSimulator = messageChannel && this.options.messageSimulators && this.options.messageSimulators[messageChannel]; - // should we start an extension editor? - if (messageSimulator) { + const simulatorExtension = messageChannel && + this.options.simulatorExtensions && + this.options.simulatorExtensions[messageChannel]; + + const startSimulatorExtension = (url: string, permanent: boolean) => { + let wrapper = this.createFrame(url); + this.container.appendChild(wrapper); + const messageFrame = wrapper.firstElementChild as HTMLIFrameElement; + messageFrame.dataset[FRAME_DATA_MESSAGE_CHANNEL] = messageChannel; + pxsim.U.addClass(wrapper, "simmsg") + pxsim.U.addClass(wrapper, "simmsg" + messageChannel) + if (permanent) + messageFrame.dataset[PERMANENT] = "true"; + this.startFrame(messageFrame); + frames = this.simFrames(); // refresh + } + + // should we start a simulator extension for this message? + if (simulatorExtension) { + // find a frame already running that simulator + let messageFrame = frames.find(frame => frame.dataset[FRAME_DATA_MESSAGE_CHANNEL] === messageChannel); + // not found, spin a new one + if (!messageFrame) { + const url = new URL(simulatorExtension.url); + if (this.options.parentOrigin) + url.searchParams.set("parentOrigin", encodeURIComponent(this.options.parentOrigin)); + if (this.options.userLanguage) + url.searchParams.set("language", encodeURIComponent(this.options.userLanguage)); + startSimulatorExtension(url.toString(), simulatorExtension.permanent); + } + // not running the current run, restart + else if (messageFrame.dataset['runid'] != this.runId) { + this.startFrame(messageFrame); + } + } + // (legacy: messageSimulator) should we start a message simulator for this message? + else if (messageSimulator) { // find a frame already running that simulator let messageFrame = frames.find(frame => frame.dataset[FRAME_DATA_MESSAGE_CHANNEL] === messageChannel); // not found, spin a new one @@ -359,16 +434,7 @@ namespace pxsim { const url = ((useLocalHost && messageSimulator.localHostUrl) || messageSimulator.url) .replace("$PARENT_ORIGIN$", encodeURIComponent(this.options.parentOrigin || "")) .replace("$LANGUAGE$", encodeURIComponent(this.options.userLanguage)) - let wrapper = this.createFrame(url); - this.container.appendChild(wrapper); - messageFrame = wrapper.firstElementChild as HTMLIFrameElement; - messageFrame.dataset[FRAME_DATA_MESSAGE_CHANNEL] = messageChannel; - pxsim.U.addClass(wrapper, "simmsg") - pxsim.U.addClass(wrapper, "simmsg" + messageChannel) - if (messageSimulator.permanent) - messageFrame.dataset[PERMANENT] = "true"; - this.startFrame(messageFrame); - frames = this.simFrames(); // refresh + startSimulatorExtension(url, messageSimulator.permanent); } // not running the curren run, restart else if (messageFrame.dataset['runid'] != this.runId) { diff --git a/webapp/src/app.tsx b/webapp/src/app.tsx index 9b245da56ef2..67c79529c64c 100644 --- a/webapp/src/app.tsx +++ b/webapp/src/app.tsx @@ -1054,9 +1054,9 @@ export class ProjectView this.initDragAndDrop(); } - public componentDidMount() { + public async componentDidMount() { this.allEditors.forEach(e => e.prepare()) - simulator.init(document.getElementById("boardview"), { + await simulator.initAsync(document.getElementById("boardview"), { orphanException: brk => { // TODO: start debugging session // TODO: user friendly error message @@ -1119,9 +1119,10 @@ export class ProjectView pkg.mainEditorPkg().setSimState(k, v) }, editor: this.state.header ? this.state.header.editor : '' - }) - this.forceUpdate(); // we now have editors prepared + }); + // we now have editors prepared + this.forceUpdate(); // start blockly load this.loadBlocklyAsync(); diff --git a/webapp/src/simulator.ts b/webapp/src/simulator.ts index d0d9241067e2..ecceacdc33f5 100644 --- a/webapp/src/simulator.ts +++ b/webapp/src/simulator.ts @@ -3,6 +3,7 @@ import * as core from "./core"; import * as coretsx from "./coretsx"; +import * as data from "./data"; import U = pxt.U import { postHostMessageAsync, shouldPostHostMessages } from "../../pxteditor"; @@ -36,7 +37,7 @@ export function setTranslations(translations: pxt.Map) { } } -export function init(root: HTMLElement, cfg: SimulatorConfig) { +export async function initAsync(root: HTMLElement, cfg: SimulatorConfig) { if (!root) return; pxsim.U.clear(root); const simulatorsDiv = document.createElement('div'); @@ -48,6 +49,8 @@ export function init(root: HTMLElement, cfg: SimulatorConfig) { debuggerDiv.className = 'ui item landscape only'; root.appendChild(debuggerDiv); + const trgConfig = await data.getAsync("target-config:") + const nestedEditorSim = /nestededitorsim=1/i.test(window.location.href); const mpRole = /[\&\?]mp=(server|client)/i.exec(window.location.href)?.[1]?.toLowerCase(); let parentOrigin: string = null; @@ -66,6 +69,28 @@ export function init(root: HTMLElement, cfg: SimulatorConfig) { } } + // Map simulator extensions from approved repos + const simulatorExtensions: pxt.Map = {}; + Object.entries(trgConfig?.packages?.approvedRepoLib || {}) + .map(([k, v]) => ({ k: k, v: v.simx })) + .filter(x => !!x.v) + .forEach(x => simulatorExtensions[x.k] = { + index: "index.html", // default to index.html + aspectRatio: pxt.appTarget.simulator.aspectRatio || 1.22, // fallback to 1.22 + permanent: true, // default to true + ...x.v + }); + // Add in test simulator extensions + Object.entries(pxt.appTarget?.simulator?.testSimulatorExtensions || {}) + .map(([k, v]) => ({ k: k, v: v as pxt.SimulatorExtensionConfig })) + .filter(x => !!x.v) + .forEach(x => simulatorExtensions[x.k] = { + index: "index.html", // default to index.html + aspectRatio: pxt.appTarget.simulator.aspectRatio || 1.22, // fallback to 1.22 + permanent: true, // default to true + ...x.v + }); + let options: pxsim.SimulatorDriverOptions = { restart: () => cfg.restartSimulator(), revealElement: (el) => { @@ -241,6 +266,7 @@ export function init(root: HTMLElement, cfg: SimulatorConfig) { parentOrigin, mpRole, messageSimulators: pxt.appTarget?.simulator?.messageSimulators, + simulatorExtensions, userLanguage: pxt.Util.userLanguage() }; driver = new pxsim.SimulatorDriver(document.getElementById('simulators'), options);