Skip to content

Commit cc1c46e

Browse files
authored
Merge pull request #50 from NativeScript/simdeck-ios
feat(ios): ship native SimDeck Studio client
2 parents 707312c + 7a53070 commit cc1c46e

254 files changed

Lines changed: 21167 additions & 154 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ packages/flutter-inspector/.dart_tool/
1717
packages/flutter-inspector/pubspec.lock
1818
docs/.vitepress/dist/
1919
docs/.vitepress/cache/
20+
ios/DerivedData/
2021
cloud/
2122
.cache/
2223
.playwright-mcp/

README.md

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,23 @@
44
<h1 align="center">SimDeck</h1>
55

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

1212
<hr/>
1313

14+
![Codex Screenshot](./assets/codex-screenshot.png)
15+
1416
## Try it out
1517

1618
```sh
1719
npx simdeck
1820
```
1921

22+
Open the URL in your IDE of choice, for example in-app browser in Codex.
23+
2024
Install the CLI globally for agentic-use:
2125

2226
```sh
@@ -35,11 +39,10 @@ view inside the editor.
3539

3640
## Features
3741

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

60-
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.
6163
To focus a specific simulator by name or UDID, pass it as the only argument:
6264

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

74+
For pairing with SimDeck iOS app:
75+
76+
```sh
77+
simdeck pair
78+
```
79+
80+
This starts or refreshes the global LaunchAgent-backed SimDeck service, prints
81+
local, LAN, and Tailscale URLs when available, and shows a QR code with a
82+
`simdeck://pair` link. The QR contains the pairing code plus all detected
83+
non-loopback addresses, so pairing once can save both the LAN and Tailscale
84+
routes with the same service token.
85+
7286
CLI commands automatically use the same warm daemon:
7387

7488
```sh

assets/codex-screenshot.png

1.16 MB
Loading

cli/XCWChromeRenderer.m

Lines changed: 567 additions & 60 deletions
Large diffs are not rendered by default.

client/package-lock.json

Lines changed: 0 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

client/src/api/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,10 @@ export interface ChromeProfile {
231231
screenY: number;
232232
screenWidth: number;
233233
screenHeight: number;
234+
contentX?: number;
235+
contentY?: number;
236+
contentWidth?: number;
237+
contentHeight?: number;
234238
cornerRadius: number;
235239
cornerRadii?: {
236240
topLeft?: number;

client/src/app/AppShell.tsx

Lines changed: 93 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ import {
8484
buildShellRotationTransform,
8585
clampPan,
8686
clampZoom,
87+
computeChromeBackingRect,
8788
computeChromeScreenBorderRadius,
8889
computeChromeScreenRect,
8990
normalizeQuarterTurns,
@@ -149,6 +150,7 @@ const STREAM_TRANSPORT_VALUES = new Set<StreamTransport>([
149150
"webrtc",
150151
]);
151152
const MOBILE_VIEWPORT_MEDIA_QUERY = "(max-width: 600px)";
153+
const CHROME_RENDERER_ASSET_VERSION = "chrome-renderer-watch-bezel-inset-22";
152154
clearLegacyVolatileUiState();
153155

154156
interface StreamQualityResponse {
@@ -211,6 +213,63 @@ function buildAuthenticatedAssetUrl(
211213
return url.toString();
212214
}
213215

216+
function chromeStampNumber(value: number | undefined): string {
217+
return Number.isFinite(value) ? String(Math.round((value ?? 0) * 1000)) : "0";
218+
}
219+
220+
function chromeStampText(value: string | undefined | null): string {
221+
return (value ?? "").replace(/[^a-zA-Z0-9_.-]+/g, "_");
222+
}
223+
224+
function buildChromeProfileAssetStamp(profile: ChromeProfile | null): string {
225+
if (!profile) {
226+
return "";
227+
}
228+
229+
const geometryStamp = [
230+
profile.totalWidth,
231+
profile.totalHeight,
232+
profile.screenX,
233+
profile.screenY,
234+
profile.screenWidth,
235+
profile.screenHeight,
236+
profile.contentX,
237+
profile.contentY,
238+
profile.contentWidth,
239+
profile.contentHeight,
240+
profile.cornerRadius,
241+
]
242+
.map(chromeStampNumber)
243+
.join("x");
244+
const maskStamp = profile.hasScreenMask ? "mask" : "nomask";
245+
const buttonStamp = [...(profile.buttons ?? [])]
246+
.sort((left, right) => left.name.localeCompare(right.name))
247+
.map((button) =>
248+
[
249+
chromeStampText(button.name),
250+
chromeStampText(button.type),
251+
chromeStampText(button.imageName),
252+
chromeStampText(button.imageDownName),
253+
chromeStampText(button.anchor),
254+
chromeStampText(button.align),
255+
button.onTop ? "top" : "under",
256+
chromeStampNumber(button.x),
257+
chromeStampNumber(button.y),
258+
chromeStampNumber(button.width),
259+
chromeStampNumber(button.height),
260+
chromeStampNumber(button.normalOffset?.x),
261+
chromeStampNumber(button.normalOffset?.y),
262+
chromeStampNumber(button.rolloverOffset?.x),
263+
chromeStampNumber(button.rolloverOffset?.y),
264+
String(button.usagePage ?? ""),
265+
String(button.usage ?? ""),
266+
].join(","),
267+
)
268+
.join(";");
269+
270+
return [geometryStamp, maskStamp, buttonStamp].filter(Boolean).join(":");
271+
}
272+
214273
function shouldUseRemoteStreamDefault(apiRoot: string): boolean {
215274
if (apiRoot) {
216275
return true;
@@ -918,23 +977,25 @@ export function AppShell({
918977
button.name.toLowerCase() === "digital-crown",
919978
),
920979
);
980+
const chromeGeometryStamp = buildChromeProfileAssetStamp(
981+
viewportChromeProfile,
982+
);
921983
const chromeAssetStamp = [
922984
selectedSimulator?.deviceTypeIdentifier,
923985
selectedSimulator?.deviceTypeName,
924986
selectedSimulator?.runtimeIdentifier,
925987
selectedSimulator?.runtimeName,
926988
selectedSimulator?.udid,
927-
chromeHasInteractiveButtons ? "buttons" : "no-buttons",
989+
chromeGeometryStamp,
990+
CHROME_RENDERER_ASSET_VERSION,
991+
chromeHasInteractiveButtons ? "baked-buttons" : "no-buttons",
928992
chromeHasCrown ? "crown" : "no-crown",
929993
]
930994
.filter(Boolean)
931995
.join(":");
996+
const chromeButtonsRenderedInChrome = chromeHasInteractiveButtons;
932997
const chromeUrl = selectedSimulator
933-
? buildChromeUrl(
934-
selectedSimulator.udid,
935-
chromeAssetStamp,
936-
!chromeHasInteractiveButtons || chromeHasCrown,
937-
)
998+
? buildChromeUrl(selectedSimulator.udid, chromeAssetStamp, true)
938999
: "";
9391000
const chromeButtonUrl = useCallback(
9401001
(button: string, pressed = false) =>
@@ -963,10 +1024,12 @@ export function AppShell({
9631024
if (viewportChromeProfile.hasScreenMask) {
9641025
urls.add(buildScreenMaskUrl(selectedSimulator.udid, chromeAssetStamp));
9651026
}
966-
for (const button of viewportChromeProfile.buttons ?? []) {
967-
urls.add(chromeButtonUrl(button.name, false));
968-
if (button.imageDownName) {
969-
urls.add(chromeButtonUrl(button.name, true));
1027+
if (!chromeButtonsRenderedInChrome) {
1028+
for (const button of viewportChromeProfile.buttons ?? []) {
1029+
urls.add(chromeButtonUrl(button.name, false));
1030+
if (button.imageDownName) {
1031+
urls.add(chromeButtonUrl(button.name, true));
1032+
}
9701033
}
9711034
}
9721035
return [...urls].filter(Boolean);
@@ -975,6 +1038,7 @@ export function AppShell({
9751038
chromeRequired,
9761039
chromeUrl,
9771040
chromeAssetStamp,
1041+
chromeButtonsRenderedInChrome,
9781042
selectedSimulator?.udid,
9791043
viewportChromeProfile,
9801044
]);
@@ -1698,10 +1762,17 @@ export function AppShell({
16981762
viewportChromeProfile,
16991763
effectiveDeviceNaturalSize,
17001764
);
1765+
const chromeScreenBackingRect = computeChromeBackingRect(
1766+
viewportChromeProfile,
1767+
);
17011768
const chromeScreenBorderRadius = computeChromeScreenBorderRadius(
17021769
viewportChromeProfile,
17031770
chromeScreenRect,
17041771
);
1772+
const chromeScreenBackingBorderRadius = computeChromeScreenBorderRadius(
1773+
viewportChromeProfile,
1774+
chromeScreenBackingRect,
1775+
);
17051776
const chromeScreenStyle =
17061777
viewportChromeProfile && chromeScreenRect
17071778
? ({
@@ -1731,6 +1802,16 @@ export function AppShell({
17311802
: {}),
17321803
} satisfies CSSProperties)
17331804
: null;
1805+
const chromeScreenBackingStyle =
1806+
viewportChromeProfile && chromeScreenBackingRect
1807+
? ({
1808+
left: `${(chromeScreenBackingRect.x / viewportChromeProfile.totalWidth) * 100}%`,
1809+
top: `${(chromeScreenBackingRect.y / viewportChromeProfile.totalHeight) * 100}%`,
1810+
width: `${(chromeScreenBackingRect.width / viewportChromeProfile.totalWidth) * 100}%`,
1811+
height: `${(chromeScreenBackingRect.height / viewportChromeProfile.totalHeight) * 100}%`,
1812+
borderRadius: chromeScreenBackingBorderRadius ?? "0",
1813+
} satisfies CSSProperties)
1814+
: null;
17341815
const screenOnlyStyle =
17351816
!viewportChromeProfile && chromeProfile && chromeProfile.screenWidth > 0
17361817
? isAndroidViewport
@@ -2746,6 +2827,8 @@ export function AppShell({
27462827
chromeLoaded={chromeLoaded}
27472828
chromeProfile={viewportChromeProfile}
27482829
chromeRequired={chromeRequired}
2830+
chromeButtonsRenderedInChrome={chromeButtonsRenderedInChrome}
2831+
chromeScreenBackingStyle={chromeScreenBackingStyle}
27492832
chromeScreenStyle={viewportScreenStyle}
27502833
chromeUrl={chromeUrl}
27512834
chromeButtonUrl={chromeButtonUrl}

0 commit comments

Comments
 (0)