Skip to content
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
30 changes: 16 additions & 14 deletions client/src/components/features/app-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -646,19 +646,21 @@ export const AppHeader: React.FC<AppHeaderProps> = ({
transform: "translate(-50%, -50%)",
}}
>
<Button
onClick={simulateAction}
disabled={simulateDisabled}
className={_getDesktopSimulateButtonClass(simulationStatus, simulateDisabled)}
data-testid="button-simulate-toggle"
aria-label={simulateLabel}
>
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 flex items-center gap-2 pointer-events-none">
<DesktopSimulateIcon isLoading={isLoading} isRunning={isRunning} />
<span className="font-semibold leading-none">{simulateText}</span>
</div>
<div className="relative flex items-stretch h-fit">
<Button
onClick={simulateAction}
disabled={simulateDisabled}
className={_getDesktopSimulateButtonClass(simulationStatus, simulateDisabled)}
data-testid="button-simulate-toggle"
aria-label={simulateLabel}
>
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 flex items-center gap-2 pointer-events-none">
<DesktopSimulateIcon isLoading={isLoading} isRunning={isRunning} />
<span className="font-semibold leading-none">{simulateText}</span>
</div>
</Button>
{isRunning && <PauseButton {...pauseProps} />}
</Button>
</div>
</div>

{/* Right: Optional telemetry/extra controls */}
Expand All @@ -675,7 +677,7 @@ export const AppHeader: React.FC<AppHeaderProps> = ({
data-mobile-header
className="bg-card border-b border-border px-4 h-[var(--ui-header-height)] flex items-center justify-center flex-nowrap overflow-hidden w-full"
>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 relative h-full">
<Button
onClick={simulateAction}
disabled={simulateDisabled}
Expand All @@ -684,8 +686,8 @@ export const AppHeader: React.FC<AppHeaderProps> = ({
aria-label={simulateLabel}
>
<MobileSimulateContent isLoading={isLoading} isRunning={isRunning} text={simulateText} />
{isRunning && <PauseButton {...pauseProps} />}
</Button>
{isRunning && <PauseButton {...pauseProps} />}
</div>
</header>
);
Expand Down
182 changes: 182 additions & 0 deletions client/src/hooks/use-external-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { useEffect } from "react";
import { SimulatorActionType, API_VERSION, SimulatorEventType } from "@/types/external-api";
import type { SimulatorMessage, SimulatorResponse, SimulatorEventMessage } from "@/types/external-api";

export interface UseExternalApiParams {
/** Restrict inbound messages to this origin. Use "*" to allow all origins. */
allowedOrigin: string;
/** Called when a LOAD_CODE message is received. */
onLoadCode: (code: string) => void;
/** Called when a START_SIMULATION message is received. */
onStartSimulation: () => void;
/** Called when a STOP_SIMULATION message is received. */
onStopSimulation: () => void;
/** Called when a SET_PIN_STATE message is received. */
onSetPinState: (pin: number, value: number) => void;
/** Returns the current value of a pin (used for GET_PIN_STATE responses). */
getPinState: (pin: number) => number;
}

// Global storage for the allowed origin (set by useExternalApi hook)
const _allowedOriginRef = { value: "*" };

/**
* Sends a response message to the parent frame.
* Automatically includes the API version for backward compatibility negotiation.
*
* @param response - The response payload to send. May or may not include version already.
* @param targetOrigin - The target origin for the postMessage call.
* Pass the parent origin explicitly to prevent data leakage (S2819).
* Use `"*"` only when the caller intentionally allows any receiver.
*/
export function sendMessageToParent(
response: Partial<SimulatorResponse>,
targetOrigin: string,
): void {
const withVersion: SimulatorResponse = {
...response,
version: API_VERSION,
} as SimulatorResponse;
globalThis.postMessage(withVersion, targetOrigin);
}

/**
* Sends an event message (emitted proactively by the simulator) to the parent frame.
* Automatically includes the API version.
*
* @param event - The event message to send (already includes version and type).
* @param targetOrigin - The target origin for the postMessage call.
*/
export function sendEventToParent(
event: SimulatorEventMessage,
targetOrigin: string,
): void {
globalThis.postMessage(event, targetOrigin);
}

/**
* Hook that listens for inbound `window.postMessage` messages and
* dispatches them to the appropriate simulator callbacks.
*
* Security: messages from origins other than `allowedOrigin` are silently
* dropped (unless `allowedOrigin` is `"*"`).
*/
export function useExternalApi(params: UseExternalApiParams): void {
const {
allowedOrigin,
onLoadCode,
onStartSimulation,
onStopSimulation,
onSetPinState,
getPinState,
} = params;

// Store the allowed origin globally for use by event-sending functions
useEffect(() => {
_allowedOriginRef.value = allowedOrigin;
}, [allowedOrigin]);

useEffect(() => {
const handleMessage = (event: MessageEvent): void => {
// ── Security check ──────────────────────────────────────────────────
if (allowedOrigin !== "*" && event.origin !== allowedOrigin) {
return;
}

const msg = event.data;

// ── Guard: must be a plain object with a `type` string ───────────────
if (typeof msg !== "object" || msg === null || typeof msg.type !== "string") {
return;
}

const message = msg as SimulatorMessage;

switch (message.type) {
case SimulatorActionType.LOAD_CODE: {
const payload = message.payload as { code: string };
onLoadCode(payload.code);
break;
}

case SimulatorActionType.START_SIMULATION: {
onStartSimulation();
break;
}

case SimulatorActionType.STOP_SIMULATION: {
onStopSimulation();
break;
}

case SimulatorActionType.SET_PIN_STATE: {
const payload = message.payload as { pin: number; value: number };
onSetPinState(payload.pin, payload.value);
break;
}

case SimulatorActionType.GET_PIN_STATE: {
const payload = message.payload as { pin: number };
const value = getPinState(payload.pin);
sendMessageToParent(
{ type: SimulatorActionType.GET_PIN_STATE, success: true, data: value },
allowedOrigin,
);
break;
}

case SimulatorActionType.BATCH_SET_PIN_STATE: {
const payload = message.payload as { pins: Array<{ pin: number; value: number }> };
if (Array.isArray(payload.pins)) {
for (const pinState of payload.pins) {
onSetPinState(pinState.pin, pinState.value);
}
}
break;
}

default:
// Unknown action — silently ignore
break;
}
};

window.addEventListener("message", handleMessage);
return () => {
window.removeEventListener("message", handleMessage);
};
}, [allowedOrigin, onLoadCode, onStartSimulation, onStopSimulation, onSetPinState, getPinState]);
}

/**
* Gets the currently configured allowed origin for external API communication.
* Defaults to "*" if useExternalApi has not been called yet.
* @internal Used by other hooks to send events with the correct origin.
*/
export function getAllowedOrigin(): string {
return _allowedOriginRef.value;
}

/**
* Sends a SERIAL_OUTPUT_EVENT to the parent frame with serial data.
* Automatically uses the configured allowed origin and includes API version.
* Safely handles errors to prevent serial output from blocking the simulator.
* @param output - The serial output string to send.
*/
export function emitSerialOutput(output: string): void {
try {
const event: SimulatorEventMessage = {
version: API_VERSION,
type: SimulatorEventType.SERIAL_OUTPUT_EVENT,
success: true,
payload: { output },
};
sendEventToParent(event, getAllowedOrigin());
} catch (error) {
// Silently ignore postMessage errors to prevent disrupting serial output
// This is expected when the simulator is not embedded in an iframe
if (typeof console !== "undefined" && console.debug) {
console.debug("[External API] Serial event send failed (expected when not in iframe):", error);
}
}
}
31 changes: 30 additions & 1 deletion client/src/hooks/use-serial-io.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import type { OutputLine } from "@shared/schema";
import { SerialCharacterRenderer } from "@/utils/serial-character-renderer";
import { emitSerialOutput } from "./use-external-api";

export function useSerialIO() {
const [serialOutput, setSerialOutput] = useState<OutputLine[]>([]);
Expand All @@ -11,6 +12,8 @@ export function useSerialIO() {
// Baudrate-simulated rendering
const [renderedSerialText, setRenderedSerialText] = useState<string>("");
const rendererRef = useRef<SerialCharacterRenderer | null>(null);
const emitDebounceRef = useRef<NodeJS.Timeout | null>(null);
const pendingOutputRef = useRef<string>("");

// Convert renderedSerialText to OutputLine[] format for SerialMonitor
const renderedSerialOutput = useMemo<OutputLine[]>(() => {
Expand All @@ -23,7 +26,7 @@ export function useSerialIO() {
}));
}, [renderedSerialText]);

// Initialize renderer once
// Initialize renderer once and cleanup on unmount
useEffect(() => {
const renderer = new SerialCharacterRenderer((char: string) => {
setRenderedSerialText((prev) => prev + char);
Expand All @@ -32,6 +35,10 @@ export function useSerialIO() {

return () => {
renderer.clear();
// Clean up debounce timer on unmount
if (emitDebounceRef.current !== null) {
clearTimeout(emitDebounceRef.current);
}
};
}, []);

Expand All @@ -57,6 +64,13 @@ export function useSerialIO() {
rendererRef.current.resume();
}
setRenderedSerialText("");

// Clean up any pending debounced emit
if (emitDebounceRef.current !== null) {
clearTimeout(emitDebounceRef.current);
emitDebounceRef.current = null;
}
pendingOutputRef.current = "";
}, []);

// Baudrate rendering methods
Expand All @@ -69,6 +83,21 @@ export function useSerialIO() {
} else {
rendererRef.current?.enqueue(text);
}

// Emit serial output event to parent frame for dashboard monitoring (debounced to 100ms)
pendingOutputRef.current += text;

if (emitDebounceRef.current !== null) {
clearTimeout(emitDebounceRef.current);
}

emitDebounceRef.current = setTimeout(() => {
if (pendingOutputRef.current) {
emitSerialOutput(pendingOutputRef.current);
pendingOutputRef.current = "";
}
emitDebounceRef.current = null;
}, 100);
}, []);

const setBaudrate = useCallback((baud: number | undefined) => {
Expand Down
20 changes: 20 additions & 0 deletions client/src/hooks/useArduinoSimulatorPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { useDebugConsole } from "@/hooks/use-debug-console";
import { useEditorCommands } from "@/hooks/use-editor-commands";
import { useFileSystem } from "@/hooks/useFileSystem";
import { useSimulatorFileSystem } from "@/hooks/useSimulatorFileSystem";
import { useExternalApi } from "@/hooks/use-external-api";
import { parseStaticIORegistry } from "@shared/io-registry-parser";

import type {
Expand Down Expand Up @@ -628,6 +629,25 @@ export function useArduinoSimulatorPage() {
}
}, [simulationStatus, setShowCompilationOutput]);

// External postMessage API — remote control for embedding websites.
// The allowed origin is detected from the parent frame (ancestorOrigins).
const externalAllowedOrigin =
globalThis.location.ancestorOrigins?.[0] ?? "*";

useExternalApi({
allowedOrigin: externalAllowedOrigin,
onLoadCode: setCode,
onStartSimulation: compileAndStartAction,
onStopSimulation: handleStop,
onSetPinState: (pin, value) => {
sendMessage({ type: "pin_state", pin, stateType: "value", value });
},
getPinState: (pin) => {
const found = pinStates.find((p) => p.pin === pin);
return found?.value ?? 0;
},
});

const state = {
showErrorGlitch,
backendReachable,
Expand Down
Loading
Loading