Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[stable11.1] cherry-pick simx support to stable #10232

Merged
merged 4 commits into from
Oct 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,11 @@
"args": [
"serve",
"--rebundle",
"--noauth"
"--noauth",
"--hostname",
"127.0.0.1",
"--backport",
"8080"
],
"cwd": "${workspaceRoot}/../pxt-microbit",
"runtimeExecutable": null,
Expand Down
6 changes: 6 additions & 0 deletions cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}))
}

Expand Down Expand Up @@ -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);
Expand Down
44 changes: 44 additions & 0 deletions cli/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,7 @@ export interface ServeOptions {
wsPort?: number;
serial?: boolean;
noauth?: boolean;
backport?: number;
}

// can use http://localhost:3232/streams/nnngzlzxslfu for testing
Expand Down Expand Up @@ -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<string | string[]> = querystring.parse(url.parse(req.url).query);
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -1248,6 +1278,20 @@ export function serveAsync(options: ServeOptions) {
}
}

// Look for an .html file corresponding to `/---<pathname>`
// Handles serving of `trg-<target>.sim.local:<port>/---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` });
Expand Down
18 changes: 18 additions & 0 deletions common-docs/extensions/simulator-extensions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Simulator Extensions

A simulator extension is a static web application that complements a traditional MakeCode extension. When you add an extension containing a simulator extension to your MakeCode project, the simulator extension is loaded into a separate iframe within the MakeCode editor. This enables extensions to display custom UI when the project is running.

> [!IMPORTANT]
> Simulator extensions are only supported for [Approved Extensions](./approval.md), and they must go through an additional approval process. The MakeCode team reserves the right to approve or decline simulator extensions at their discretion. We strongly recommend consulting with the MakeCode team before investing time and resources into creating a simulator extension.

## Example simulator extensions

* [pxt-simx-sample](https://github.com/microsoft/pxt-simx-sample)
* [pxt-arcadeshield](https://github.com/microsoft/pxt-arcadeshield)
* [microbit-robot](https://github.com/microsoft/microbit-robot)
* [pxt-jacdac](https://github.com/microsoft/)

## Creating a simulator extension

Refer to the [pxt-simx-sample](https://github.com/microsoft/pxt-simx-sample) project for detailed instructions.

17 changes: 17 additions & 0 deletions localtypings/pxtarget.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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<SimulatorExtensionConfig>;
}

interface TargetCompileService {
Expand Down
2 changes: 1 addition & 1 deletion pxtlib/browserutils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
28 changes: 19 additions & 9 deletions pxtsim/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ namespace pxsim {
return Promise.all(values.map(v => mapper(v)));
}

export function promiseMapAllSeries<T, V>(values: T[], mapper: (obj: T) => Promise<V>): Promise<V[]> {
export function promiseMapAllSeries<T, V>(values: T[], mapper: (obj: T) => Promise<V>): Promise<V[]> {
return promisePoolAsync(1, values, mapper);
}

Expand Down Expand Up @@ -193,7 +193,7 @@ namespace pxsim {
}, ms);
});

return Promise.race([ promise, timeoutPromise ])
return Promise.race([promise, timeoutPromise])
.then(output => {
// clear any dangling timeout
if (res) {
Expand Down Expand Up @@ -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; }
}

Expand All @@ -309,6 +311,14 @@ namespace pxsim {
})
return v;
}

export function sanitizeCssName(name: string): string {
let sanitized = name.replace(/[^a-zA-Z0-9-_]/g, '_');
if (!/^[a-zA-Z_]/.test(sanitized)) {
sanitized = 'cls_' + sanitized;
}
return sanitized;
}
}

export interface Map<T> {
Expand Down Expand Up @@ -573,7 +583,7 @@ namespace pxsim {

class EventHandler {
private busy = 0;
constructor(public handler: RefAction, public flags: number) {}
constructor(public handler: RefAction, public flags: number) { }

async runAsync(eventValue: EventIDType, runtime: Runtime, valueToArgs?: EventValueToActionArgs) {
// The default behavior can technically be configured in codal, but we always set it to queue if busy
Expand All @@ -595,9 +605,9 @@ namespace pxsim {
}

private async runFiberAsync(eventValue: EventIDType, runtime: Runtime, valueToArgs?: EventValueToActionArgs) {
this.busy ++;
this.busy++;
await runtime.runFiberAsync(this.handler, ...(valueToArgs ? valueToArgs(eventValue) : [eventValue]));
this.busy --;
this.busy--;
}
}

Expand Down Expand Up @@ -678,7 +688,7 @@ namespace pxsim {
this._handlers = [new EventHandler(a, flags)];
}
else {
this._addRemoveLog.push({ act: a, log: LogType.UserSet, flags});
this._addRemoveLog.push({ act: a, log: LogType.UserSet, flags });
}
}

Expand Down
115 changes: 92 additions & 23 deletions pxtsim/simdriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -120,16 +131,46 @@ 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/<sha>---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("/");
// Create the fully-qualified URL, preserving the origin by removing all leading slashes
simx.url = new URL(simxPath.replace(/^\/+/, ""), 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);
}

Expand Down Expand Up @@ -349,8 +390,45 @@ 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, aspectRatio: number) => {
aspectRatio = aspectRatio || this._runOptions?.aspectRatio || 1.22;
let wrapper = this.createFrame(url);
this.container.appendChild(wrapper);
const messageFrame = wrapper.firstElementChild as HTMLIFrameElement;
messageFrame.dataset[FRAME_DATA_MESSAGE_CHANNEL] = messageChannel;
messageFrame.dataset[FRAME_ASPECT_RATIO] = aspectRatio + "";
pxsim.U.addClass(wrapper, "simmsg")
pxsim.U.addClass(wrapper, "simmsg" + U.sanitizeCssName(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, simulatorExtension.aspectRatio);
}
// 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
Expand All @@ -359,16 +437,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, messageSimulator.aspectRatio);
}
// not running the curren run, restart
else if (messageFrame.dataset['runid'] != this.runId) {
Expand Down
Loading