> ## Documentation Index
> Fetch the complete documentation index at: https://docs.run.cloud/llms.txt
> Use this file to discover all available pages before exploring further.

# Embed a Simulator

> Embed authenticated run.cloud iOS and Android sessions in a React application or raw iframe.

`@run-cloud/ui` renders the signed viewer URL returned for either an iOS
simulator or an Android emulator. It adds embed mode, validates browser
messages against the iframe window and its exact origin, exposes typed lifecycle
and interaction events, and removes its listeners and timers when it unmounts.

The interaction payloads, coordinates, keys, buttons, and platform capability
matrix are documented in [Control a Simulator](/platform/simulator-interactions).

## Install

```bash theme={null}
npm install @run-cloud/ui
```

The React package needs only a signed session URL. Create sessions with the
server-side `@run-cloud/sdk` client or the run.cloud API. Never put a run.cloud
API key in a browser bundle.

## Create a session on the server

Install the server SDK separately:

```bash theme={null}
npm install @run-cloud/sdk
```

First [upload a platform-compatible app build as an asset](/cli/assets). Keep
the resulting asset id on the server. The same helper can then install and
launch that app on either platform. `session.url` is the signed viewer URL
passed to the React component:

```ts theme={null}
// server/simulator-session.ts: import this file from server code only.
import { Client, type SimulatorPlatform } from "@run-cloud/sdk";

const cloud = new Client();

export async function createSimulatorSession(
  platform: SimulatorPlatform,
  appAssetId: string,
) {
  const session = await cloud.simulators.create({
    platform,
    displayName: "Embedded preview",
    tags: { surface: "product-preview" },
    installAssets: [appAssetId],
    inactivityTimeout: "60s",
    hardTimeout: "10m",
  });

  if (!session.url) {
    await cloud.simulators.delete(session.id, { platform: session.platform });
    throw new Error("The simulator session did not return a viewer URL");
  }

  return {
    id: session.id,
    platform: session.platform,
    url: session.url,
  };
}

export async function releaseSimulatorSession(
  id: string,
  platform: SimulatorPlatform,
) {
  await cloud.simulators.delete(id, { platform });
}
```

Call `createSimulatorSession` from an authenticated server route, return only
the session metadata and signed URL that the authorized browser needs, and call
`releaseSimulatorSession` from a server route when the preview is finished.
For scripts and test jobs, put `cloud.simulators.delete(...)` in `finally`.

The signed URL is a bearer secret scoped to the session. Do not place it in
logs, analytics, screenshots, or public page URLs.

## Render the React component

Copy this component as-is into a React application. Its parent has an explicit,
responsive size; `RemoteControl` fills that area and the embedded viewer adapts
to the available space.

```tsx theme={null}
"use client";

import { useRef, useState } from "react";
import {
  RemoteControl,
  type RemoteControlHandle,
  type RemoteControlStatus,
} from "@run-cloud/ui";

export type SimulatorEmbedProps = {
  url: string;
};

export default function SimulatorEmbed({ url }: SimulatorEmbedProps) {
  const remoteControl = useRef<RemoteControlHandle | null>(null);
  const [status, setStatus] = useState<RemoteControlStatus | null>(null);
  const [notice, setNotice] = useState("Connecting to the simulator...");

  return (
    <section style={{ width: "100%", maxWidth: 430 }}>
      <div
        style={{
          position: "relative",
          width: "100%",
          height: "min(80vh, 844px)",
          overflow: "hidden",
          borderRadius: 24,
          background: "#0f172a",
        }}
      >
        <RemoteControl
          ref={remoteControl}
          url={url}
          loadingGuard
          title="run.cloud simulator preview"
          onStatus={setStatus}
          onReady={() => setNotice("Simulator ready")}
          onSessionEnded={(event) =>
            setNotice(`Session ended: ${event.reason}`)
          }
          onRestartRequested={() =>
            setNotice("Create a new session to restart the preview")
          }
          onInteractionResult={(result) =>
            setNotice(
              result.ok
                ? `${result.action} completed`
                : `${result.action} failed: ${result.error.message}`,
            )
          }
          onUnavailable={(event) => setNotice(event.reason)}
        />
      </div>

      <div
        style={{
          display: "flex",
          alignItems: "center",
          gap: 12,
          marginTop: 12,
        }}
      >
        <button
          type="button"
          onClick={async () => {
            try {
              await remoteControl.current?.tap({ x: 0.5, y: 0.5 });
            } catch (error) {
              setNotice(error instanceof Error ? error.message : String(error));
            }
          }}
        >
          Tap center
        </button>
        <span aria-live="polite">
          {notice}
          {status?.streaming ? " · streaming" : ""}
        </span>
      </div>
    </section>
  );
}
```

The module is safe to import during server rendering. In Next.js, put the
interactive component behind a `"use client"` boundary because refs, state, and
event callbacks run in the browser.

Unmounting `RemoteControl` removes its iframe message listener and availability
timer. It does not release the metered run.cloud session. Always call your
server-side release path with the session id and platform.

## Component props

`RemoteControl` accepts the iframe attributes `id`, `title`, `className`,
`style`, `allow`, `draggable`, `allowTransparency`, `onLoad`, and `onError`.
These props control the iframe without changing the signed session URL.

| Prop                 | Type                    | Purpose                                                                                                       |
| -------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- |
| `url`                | `string`                | Required absolute HTTP(S) signed URL from `session.url`. Existing query parameters are preserved.             |
| `loadingGuard`       | `boolean`               | Show the embedded connecting/installing guard until the app is ready.                                         |
| `reloadKey`          | `string \| number`      | Reset component readiness and recreate the iframe when the consumer requests a full frame reload.             |
| `loadTimeoutMs`      | `number \| null`        | Time before `onUnavailable` runs. The default is 20,000 milliseconds; `null` disables the readiness timeout.  |
| `id`, `title`        | `string`                | Accessible and testable iframe identifiers.                                                                   |
| `className`, `style` | React iframe attributes | Size and style the iframe within its parent.                                                                  |
| `allow`              | `string`                | Override the iframe permissions policy when your app needs camera, microphone, location, or clipboard access. |

The component adds `embed=1`. When `loadingGuard` is `true`, it also adds
`loadingGuard=1`. It preserves the signed token and every other query
parameter.

## Events

| Prop                  | Payload                          | When it runs                                                                                                                                                                                             |
| --------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `onStatus`            | `RemoteControlStatus`            | A validated status heartbeat arrives.                                                                                                                                                                    |
| `onReady`             | `RemoteControlStatus`            | Streaming is active and either the expected app is launched or a React Native app is detected. It runs once per iframe lifecycle; changing `url`, `loadingGuard`, or `reloadKey` starts a new lifecycle. |
| `onAuthError`         | `RemoteControlAuthError`         | The viewer reports that the signed URL or session authorization is invalid.                                                                                                                              |
| `onSessionEnded`      | `RemoteControlSessionEnded`      | The active session ends, including inactivity expiry. Release any remaining server-side session state and hide the old URL.                                                                              |
| `onRestartRequested`  | `RemoteControlRestartRequested`  | The viewer asks the parent to create a new session. An ended URL cannot be reused.                                                                                                                       |
| `onCommandResult`     | `RemoteControlCommandResult`     | The iframe completes or rejects a command.                                                                                                                                                               |
| `onInteractionResult` | `RemoteControlInteractionResult` | An acknowledged interaction completes, fails, is cancelled, or times out.                                                                                                                                |
| `onUnavailable`       | `RemoteControlUnavailable`       | The iframe reports a browser-level load error or its availability timeout expires. The payload includes `reason`, `loaded`, `receivedStatus`, and `timeoutMs`.                                           |
| `onLoad`, `onError`   | React iframe event               | The iframe element loads or reports a browser-level error.                                                                                                                                               |

`RemoteControlStatus` includes `streaming`, `appLaunched`, `reactNative`,
`device`, `bundleId`, `expectedBundleId`, `orientation`, and
`accessibilityOverlay`. Treat the component as ready when:

```ts theme={null}
status.streaming && (status.appLaunched || status.reactNative)
```

## Controls

Use the acknowledged `RemoteControlHandle` methods for automation. They resolve
with the matching typed result and reject with `RemoteControlInteractionError`
when the iframe reports a failure:

| Method                                      | Effect                                                                         |
| ------------------------------------------- | ------------------------------------------------------------------------------ |
| `interact(interaction, options?)`           | Send any typed simulator interaction.                                          |
| `tap(point, options?)`                      | Tap normalized display coordinates.                                            |
| `swipe(from, to, options?)`                 | Swipe between two points.                                                      |
| `gesture(steps, options?)`                  | Run a one- or two-finger gesture.                                              |
| `typeText(text, options?)`                  | Enter US ASCII text.                                                           |
| `pressKey(key, options?)`                   | Press a semantic key with optional modifiers.                                  |
| `pressButton(button, options?)`             | Press a hardware or system button.                                             |
| `setOrientation(orientation, options?)`     | Set an absolute orientation.                                                   |
| `reloadApp(options?)`                       | Reload the foreground app.                                                     |
| `scroll(input, options?)`                   | Send normalized scroll input.                                                  |
| `toggleSoftwareKeyboard(options?)`          | Toggle the on-screen keyboard.                                                 |
| `simulateMemoryWarning(options?)`           | Send a memory-warning event.                                                   |
| `rotateDigitalCrown(delta, options?)`       | Request Digital Crown input; current mobile sessions report it as unsupported. |
| `setRenderDebug(option, enabled, options?)` | Change an iOS render diagnostic.                                               |

`setOrientation` accepts the shared four-value orientation type. Current
iPhone Simulator sessions support `portrait`, `landscape_left`, and
`landscape_right`; `portrait_upside_down` rejects with a correlated,
non-retryable `unsupported_action` failure. Android Emulator sessions support
all four values.

Options accept `requestId`, `timeoutMs`, and `signal`. The timeout defaults to
15,000 milliseconds and accepts 100 to 60,000. Aborting, timing out, changing
the frame, or removing the component sends a correlated cancellation request
before rejecting the pending promise. Cancellation is best effort and cannot
undo an action the simulator already completed.

The older fire-and-forget command channel remains available through
`sendCommand`, `reload`, `home`, `rotate`, `screenshot`, and
`toggleAccessibility`. It accepts:

| Command                                               | Effect                                      |
| ----------------------------------------------------- | ------------------------------------------- |
| `{ command: "reload" }`                               | Reload the foreground app.                  |
| `{ command: "home" }`                                 | Press Home.                                 |
| `{ command: "rotate", direction: "left" \| "right" }` | Rotate relative to the current orientation. |
| `{ command: "screenshot" }`                           | Ask the viewer to capture a screenshot.     |
| `{ command: "toggleAccessibility" }`                  | Toggle the viewer accessibility inspector.  |

On current iPhone Simulator sessions, relative viewer rotation skips
`portrait_upside_down` so the legacy control stays within renderable states.

Each accepted command produces `onCommandResult` with `command`, `ok`, and an
optional `error` string. Prefer acknowledged interactions for tests and
automation.

## Session timeouts

New sessions default to a 60-second inactivity timeout when the option is
omitted. Set a duration explicitly for predictable product behavior, or pass
`inactivityTimeout: null` in the TypeScript SDK (`--inactivity-timeout none` in
the CLI) to disable inactivity auto-close. Hard timeouts and explicit release
still apply.

Pointer, touch, wheel, and keyboard input inside the iframe reset an enabled
inactivity timer. Watching the stream without interacting does not. The viewer
shows the final countdown and emits a session-ended event when the lease closes.

## Raw iframe alternative

You can use the signed URL without React. Add `embed=1`, listen only to the
expected iframe window and exact URL origin, and use that same exact origin when
sending commands:

```ts theme={null}
export function attachSimulator(
  iframe: HTMLIFrameElement,
  sessionUrl: string,
) {
  const url = new URL(sessionUrl);
  if (url.protocol !== "https:" && url.protocol !== "http:") {
    throw new TypeError("Expected an absolute HTTP(S) simulator URL");
  }

  url.searchParams.set("embed", "1");
  iframe.src = url.toString();
  const targetOrigin = url.origin;

  const onMessage = (event: MessageEvent) => {
    if (event.source !== iframe.contentWindow) return;
    if (event.origin !== targetOrigin) return;
    if (!event.data || typeof event.data !== "object") return;

    const message = event.data as Record<string, unknown>;

    if (message.type === "ios-simulator:status") {
      const ready =
        message.streaming === true &&
        (message.appLaunched === true || message.reactNative === true);
      console.log({ ready, streaming: message.streaming });
    }

    if (
      message.type === "ios-simulator:session-ended" ||
      message.type === "ios-simulator:session-restart-requested"
    ) {
      console.log("Create a new simulator session");
    }

    if (message.type === "run-cloud:interaction-result") {
      console.log({
        requestId: message.requestId,
        action: message.action,
        ok: message.ok,
        status: message.status,
        error: message.error,
      });
    }

    if (message.type === "ios-simulator:result") {
      console.log({
        command: message.command,
        ok: message.ok,
        error: message.error,
      });
    }
  };

  window.addEventListener("message", onMessage);

  return {
    tap(x: number, y: number) {
      const requestId = crypto.randomUUID();
      iframe.contentWindow?.postMessage(
        {
          type: "run-cloud:interaction",
          requestId,
          timeoutMs: 15_000,
          action: "tap",
          x,
          y,
        },
        targetOrigin,
      );
      return requestId;
    },
    cancel(requestId: string, action: "tap") {
      iframe.contentWindow?.postMessage(
        { type: "run-cloud:interaction-cancel", requestId, action },
        targetOrigin,
      );
    },
    dispose() {
      window.removeEventListener("message", onMessage);
      iframe.removeAttribute("src");
    },
  };
}
```

For compatibility, both iOS simulator and Android emulator iframes use the
`ios-simulator:*` lifecycle and legacy-command message names. Acknowledged
interactions use platform-neutral `run-cloud:*` names.

| Message                                   | Direction        | Meaning                                                          |
| ----------------------------------------- | ---------------- | ---------------------------------------------------------------- |
| `ios-simulator:status`                    | iframe to parent | Stream, app, orientation, and accessibility state changed.       |
| `ios-simulator:auth-error`                | iframe to parent | The signed URL or iframe session authorization was rejected.     |
| `ios-simulator:session-ended`             | iframe to parent | The session ended and its URL must no longer be used.            |
| `ios-simulator:session-restart-requested` | iframe to parent | The viewer requests a newly created run.cloud session.           |
| `ios-simulator:result`                    | iframe to parent | A command completed or failed.                                   |
| `ios-simulator:command`                   | parent to iframe | A supported control command.                                     |
| `run-cloud:interaction-result`            | iframe to parent | A correlated interaction completed or failed.                    |
| `run-cloud:interaction`                   | parent to iframe | A correlated simulator interaction request.                      |
| `run-cloud:interaction-cancel`            | parent to iframe | Best-effort cancellation for the matching request id and action. |

Removing a raw iframe or its message listener also does not release its cloud
session. Call the authenticated server-side delete operation separately.
