Skip to content
Draft
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ packages/flutter-inspector/.dart_tool/
packages/flutter-inspector/pubspec.lock
docs/.vitepress/dist/
docs/.vitepress/cache/
ios/DerivedData/
cloud/
.cache/
.playwright-mcp/
Expand Down
26 changes: 20 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,23 @@
<h1 align="center">SimDeck</h1>

<p align="center">
SimDeck is a developer tool built for streamlining mobile app development for coding agents.
Drive iOS Simulators and Android emulators from the CLI using agents, browser, and automated tests on macOS.
SimDeck is a developer tool built for streamlining mobile app development using agents.
Drive iOS Simulators and Android emulators from browser & CLI.
</p>
</p>

<hr/>

![Codex Screenshot](./assets/codex-screenshot.png)

## Try it out

```sh
npx simdeck
```

Open the URL in your IDE of choice, for example in-app browser in Codex.

Install the CLI globally for agentic-use:

```sh
Expand All @@ -35,11 +39,10 @@ view inside the editor.

## Features

- Local iOS Simulator video over browser-native WebRTC H.264 with VideoToolbox hardware encode and x264 software encode
- Android emulator frames are sourced from emulator gRPC; loopback browsers use raw RGBA over WebRTC, and non-loopback browsers use VideoToolbox-encoded H.264
- Supports streaming both iOS simulators and Android emulators
- Full simulator control & inspection using private iOS accessibility APIs and Android UIAutomator - available using `simdeck` CLI
- Real-time screen `describe` command using accessibility view tree - available in token-efficient format for agents
- Simulator app performance gauges for CPU, memory, disk writes, network throughput, hang signals, and stack sampling
- Profiling built-in: CPU, memory, disk writes, network throughput, hang signals, and stack sampling
- CoreSimulator chrome asset rendering for device bezels
- NativeScript, React Native, Flutter, UIKit and SwiftUI runtime inspector plugins to debug app's view hierarchy live
- `simdeck/test` for fast JS-based app tests that can query accessibility state and drive simulator controls
Expand All @@ -57,7 +60,6 @@ documented in the [GitHub Actions guide](https://simdeck.nativescript.org/guide/
simdeck
```

This starts a workspace-local foreground daemon, prints local and LAN HTTP URLs plus a pairing code for LAN browsers, and stops when you press `q` or Ctrl-C.
To focus a specific simulator by name or UDID, pass it as the only argument:

```sh
Expand All @@ -69,6 +71,18 @@ simdeck "iPhone 17 Pro Max"
The served loopback browser UI receives the generated API access token automatically.
LAN clients should pair with the printed code before receiving the API cookie.

For pairing with SimDeck iOS app:

```sh
simdeck pair
```

This starts or refreshes the global LaunchAgent-backed SimDeck service, prints
local, LAN, and Tailscale URLs when available, and shows a QR code with a
`simdeck://pair` link. The QR contains the pairing code plus all detected
non-loopback addresses, so pairing once can save both the LAN and Tailscale
routes with the same service token.

CLI commands automatically use the same warm daemon:

```sh
Expand Down
Binary file added assets/codex-screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
627 changes: 567 additions & 60 deletions cli/XCWChromeRenderer.m

Large diffs are not rendered by default.

6 changes: 0 additions & 6 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions client/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,10 @@ export interface ChromeProfile {
screenY: number;
screenWidth: number;
screenHeight: number;
contentX?: number;
contentY?: number;
contentWidth?: number;
contentHeight?: number;
cornerRadius: number;
cornerRadii?: {
topLeft?: number;
Expand Down
103 changes: 93 additions & 10 deletions client/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import {
buildShellRotationTransform,
clampPan,
clampZoom,
computeChromeBackingRect,
computeChromeScreenBorderRadius,
computeChromeScreenRect,
normalizeQuarterTurns,
Expand Down Expand Up @@ -147,6 +148,7 @@ const STREAM_TRANSPORT_VALUES = new Set<StreamTransport>([
"webrtc",
]);
const MOBILE_VIEWPORT_MEDIA_QUERY = "(max-width: 600px)";
const CHROME_RENDERER_ASSET_VERSION = "chrome-renderer-watch-bezel-inset-22";
clearLegacyVolatileUiState();

interface StreamQualityResponse {
Expand Down Expand Up @@ -209,6 +211,63 @@ function buildAuthenticatedAssetUrl(
return url.toString();
}

function chromeStampNumber(value: number | undefined): string {
return Number.isFinite(value) ? String(Math.round((value ?? 0) * 1000)) : "0";
}

function chromeStampText(value: string | undefined | null): string {
return (value ?? "").replace(/[^a-zA-Z0-9_.-]+/g, "_");
}

function buildChromeProfileAssetStamp(profile: ChromeProfile | null): string {
if (!profile) {
return "";
}

const geometryStamp = [
profile.totalWidth,
profile.totalHeight,
profile.screenX,
profile.screenY,
profile.screenWidth,
profile.screenHeight,
profile.contentX,
profile.contentY,
profile.contentWidth,
profile.contentHeight,
profile.cornerRadius,
]
.map(chromeStampNumber)
.join("x");
const maskStamp = profile.hasScreenMask ? "mask" : "nomask";
const buttonStamp = [...(profile.buttons ?? [])]
.sort((left, right) => left.name.localeCompare(right.name))
.map((button) =>
[
chromeStampText(button.name),
chromeStampText(button.type),
chromeStampText(button.imageName),
chromeStampText(button.imageDownName),
chromeStampText(button.anchor),
chromeStampText(button.align),
button.onTop ? "top" : "under",
chromeStampNumber(button.x),
chromeStampNumber(button.y),
chromeStampNumber(button.width),
chromeStampNumber(button.height),
chromeStampNumber(button.normalOffset?.x),
chromeStampNumber(button.normalOffset?.y),
chromeStampNumber(button.rolloverOffset?.x),
chromeStampNumber(button.rolloverOffset?.y),
String(button.usagePage ?? ""),
String(button.usage ?? ""),
].join(","),
)
.join(";");

return [geometryStamp, maskStamp, buttonStamp].filter(Boolean).join(":");
}

function shouldUseRemoteStreamDefault(apiRoot: string): boolean {
if (apiRoot) {
return true;
Expand Down Expand Up @@ -910,23 +969,25 @@ export function AppShell({
button.name.toLowerCase() === "digital-crown",
),
);
const chromeGeometryStamp = buildChromeProfileAssetStamp(
viewportChromeProfile,
);
const chromeAssetStamp = [
selectedSimulator?.deviceTypeIdentifier,
selectedSimulator?.deviceTypeName,
selectedSimulator?.runtimeIdentifier,
selectedSimulator?.runtimeName,
selectedSimulator?.udid,
chromeHasInteractiveButtons ? "buttons" : "no-buttons",
chromeGeometryStamp,
CHROME_RENDERER_ASSET_VERSION,
chromeHasInteractiveButtons ? "baked-buttons" : "no-buttons",
chromeHasCrown ? "crown" : "no-crown",
]
.filter(Boolean)
.join(":");
const chromeButtonsRenderedInChrome = chromeHasInteractiveButtons;
const chromeUrl = selectedSimulator
? buildChromeUrl(
selectedSimulator.udid,
chromeAssetStamp,
!chromeHasInteractiveButtons || chromeHasCrown,
)
? buildChromeUrl(selectedSimulator.udid, chromeAssetStamp, true)
: "";
const chromeButtonUrl = useCallback(
(button: string, pressed = false) =>
Expand Down Expand Up @@ -955,10 +1016,12 @@ export function AppShell({
if (viewportChromeProfile.hasScreenMask) {
urls.add(buildScreenMaskUrl(selectedSimulator.udid, chromeAssetStamp));
}
for (const button of viewportChromeProfile.buttons ?? []) {
urls.add(chromeButtonUrl(button.name, false));
if (button.imageDownName) {
urls.add(chromeButtonUrl(button.name, true));
if (!chromeButtonsRenderedInChrome) {
for (const button of viewportChromeProfile.buttons ?? []) {
urls.add(chromeButtonUrl(button.name, false));
if (button.imageDownName) {
urls.add(chromeButtonUrl(button.name, true));
}
}
}
return [...urls].filter(Boolean);
Expand All @@ -967,6 +1030,7 @@ export function AppShell({
chromeRequired,
chromeUrl,
chromeAssetStamp,
chromeButtonsRenderedInChrome,
selectedSimulator?.udid,
viewportChromeProfile,
]);
Expand Down Expand Up @@ -1673,10 +1737,17 @@ export function AppShell({
viewportChromeProfile,
effectiveDeviceNaturalSize,
);
const chromeScreenBackingRect = computeChromeBackingRect(
viewportChromeProfile,
);
const chromeScreenBorderRadius = computeChromeScreenBorderRadius(
viewportChromeProfile,
chromeScreenRect,
);
const chromeScreenBackingBorderRadius = computeChromeScreenBorderRadius(
viewportChromeProfile,
chromeScreenBackingRect,
);
const chromeScreenStyle =
viewportChromeProfile && chromeScreenRect
? ({
Expand Down Expand Up @@ -1706,6 +1777,16 @@ export function AppShell({
: {}),
} satisfies CSSProperties)
: null;
const chromeScreenBackingStyle =
viewportChromeProfile && chromeScreenBackingRect
? ({
left: `${(chromeScreenBackingRect.x / viewportChromeProfile.totalWidth) * 100}%`,
top: `${(chromeScreenBackingRect.y / viewportChromeProfile.totalHeight) * 100}%`,
width: `${(chromeScreenBackingRect.width / viewportChromeProfile.totalWidth) * 100}%`,
height: `${(chromeScreenBackingRect.height / viewportChromeProfile.totalHeight) * 100}%`,
borderRadius: chromeScreenBackingBorderRadius ?? "0",
} satisfies CSSProperties)
: null;
const screenOnlyStyle =
!viewportChromeProfile && chromeProfile && chromeProfile.screenWidth > 0
? isAndroidViewport
Expand Down Expand Up @@ -2716,6 +2797,8 @@ export function AppShell({
chromeLoaded={chromeLoaded}
chromeProfile={viewportChromeProfile}
chromeRequired={chromeRequired}
chromeButtonsRenderedInChrome={chromeButtonsRenderedInChrome}
chromeScreenBackingStyle={chromeScreenBackingStyle}
chromeScreenStyle={viewportScreenStyle}
chromeUrl={chromeUrl}
chromeButtonUrl={chromeButtonUrl}
Expand Down
Loading
Loading