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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@ 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 native iOS pairing, run:

```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:

Expand Down
163 changes: 124 additions & 39 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.

50 changes: 36 additions & 14 deletions client/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -910,23 +910,33 @@ export function AppShell({
button.name.toLowerCase() === "digital-crown",
),
);
const chromeGeometryStamp = viewportChromeProfile
? [
viewportChromeProfile.totalWidth,
viewportChromeProfile.totalHeight,
viewportChromeProfile.screenX,
viewportChromeProfile.screenY,
viewportChromeProfile.screenWidth,
viewportChromeProfile.screenHeight,
]
.map((value) => Math.round(value))
.join("x")
: "";
const chromeAssetStamp = [
selectedSimulator?.deviceTypeIdentifier,
selectedSimulator?.deviceTypeName,
selectedSimulator?.runtimeIdentifier,
selectedSimulator?.runtimeName,
selectedSimulator?.udid,
chromeHasInteractiveButtons ? "buttons" : "no-buttons",
chromeGeometryStamp,
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 +965,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 +979,7 @@ export function AppShell({
chromeRequired,
chromeUrl,
chromeAssetStamp,
chromeButtonsRenderedInChrome,
selectedSimulator?.udid,
viewportChromeProfile,
]);
Expand Down Expand Up @@ -1680,10 +1693,18 @@ export function AppShell({
const chromeScreenStyle =
viewportChromeProfile && chromeScreenRect
? ({
left: `${(chromeScreenRect.x / viewportChromeProfile.totalWidth) * 100}%`,
top: `${(chromeScreenRect.y / viewportChromeProfile.totalHeight) * 100}%`,
width: `${(chromeScreenRect.width / viewportChromeProfile.totalWidth) * 100}%`,
height: `${(chromeScreenRect.height / viewportChromeProfile.totalHeight) * 100}%`,
left: viewportChromeProfile.hasScreenMask
? `calc(${(chromeScreenRect.x / viewportChromeProfile.totalWidth) * 100}% - 1px)`
: `${(chromeScreenRect.x / viewportChromeProfile.totalWidth) * 100}%`,
top: viewportChromeProfile.hasScreenMask
? `calc(${(chromeScreenRect.y / viewportChromeProfile.totalHeight) * 100}% - 1px)`
: `${(chromeScreenRect.y / viewportChromeProfile.totalHeight) * 100}%`,
width: viewportChromeProfile.hasScreenMask
? `calc(${(chromeScreenRect.width / viewportChromeProfile.totalWidth) * 100}% + 2px)`
: `${(chromeScreenRect.width / viewportChromeProfile.totalWidth) * 100}%`,
height: viewportChromeProfile.hasScreenMask
? `calc(${(chromeScreenRect.height / viewportChromeProfile.totalHeight) * 100}% + 2px)`
: `${(chromeScreenRect.height / viewportChromeProfile.totalHeight) * 100}%`,
borderRadius: viewportChromeProfile.hasScreenMask
? "0"
: (chromeScreenBorderRadius ?? "0"),
Expand Down Expand Up @@ -2716,6 +2737,7 @@ export function AppShell({
chromeLoaded={chromeLoaded}
chromeProfile={viewportChromeProfile}
chromeRequired={chromeRequired}
chromeButtonsRenderedInChrome={chromeButtonsRenderedInChrome}
chromeScreenStyle={viewportScreenStyle}
chromeUrl={chromeUrl}
chromeButtonUrl={chromeButtonUrl}
Expand Down
37 changes: 24 additions & 13 deletions client/src/features/viewport/DeviceChrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ interface DeviceChromeProps {
accessibilityRoots: AccessibilityNode[];
accessibilitySelectedId: string;
chromeProfile: ChromeProfile | null;
chromeButtonsRenderedInChrome: boolean;
chromeScreenStyle: CSSProperties | null;
chromeUrl: string;
chromeButtonUrl: (button: string, pressed?: boolean) => string;
Expand Down Expand Up @@ -61,6 +62,7 @@ export function DeviceChrome({
accessibilityRoots,
accessibilitySelectedId,
chromeProfile,
chromeButtonsRenderedInChrome,
chromeScreenStyle,
chromeUrl,
chromeButtonUrl,
Expand Down Expand Up @@ -109,6 +111,7 @@ export function DeviceChrome({
chromeProfile={chromeProfile}
layer="under"
onEvent={onChromeButtonEvent}
renderImages={!chromeButtonsRenderedInChrome}
/>
<img
alt=""
Expand All @@ -122,6 +125,7 @@ export function DeviceChrome({
chromeProfile={chromeProfile}
layer="over"
onEvent={onChromeButtonEvent}
renderImages={!chromeButtonsRenderedInChrome}
/>
<ScreenLayer
accessibilityHoveredId={accessibilityHoveredId}
Expand Down Expand Up @@ -218,6 +222,7 @@ function ChromeButtonOverlay({
chromeProfile,
layer,
onEvent,
renderImages,
}: {
chromeButtonUrl: (button: string, pressed?: boolean) => string;
chromeProfile: ChromeProfile | null;
Expand All @@ -228,6 +233,7 @@ function ChromeButtonOverlay({
usagePage?: number,
usage?: number,
) => void;
renderImages: boolean;
}) {
const buttons = chromeProfile?.buttons ?? [];
if (!chromeProfile || buttons.length === 0) {
Expand All @@ -254,6 +260,7 @@ function ChromeButtonOverlay({
chromeButtonUrl={chromeButtonUrl}
key={`${button.name}-${button.x}-${button.y}`}
onEvent={onEvent}
renderImages={renderImages}
totalHeight={chromeProfile.totalHeight}
totalWidth={chromeProfile.totalWidth}
wireName={wireName}
Expand All @@ -268,6 +275,7 @@ function ChromeButtonHitTarget({
button,
chromeButtonUrl,
onEvent,
renderImages,
totalHeight,
totalWidth,
wireName,
Expand All @@ -280,6 +288,7 @@ function ChromeButtonHitTarget({
usagePage?: number,
usage?: number,
) => void;
renderImages: boolean;
totalHeight: number;
totalWidth: number;
wireName: string;
Expand Down Expand Up @@ -372,7 +381,7 @@ function ChromeButtonHitTarget({
title={label}
type="button"
>
{downCompositeUnder ? (
{renderImages && downCompositeUnder ? (
<img
alt=""
aria-hidden="true"
Expand All @@ -381,18 +390,20 @@ function ChromeButtonHitTarget({
src={pressedImageUrl}
/>
) : null}
<img
alt=""
aria-hidden="true"
className="device-chrome-button-image"
draggable={false}
src={
pressed && pressedImageUrl && !downCompositeUnder
? pressedImageUrl
: imageUrl
}
/>
{!pressed && pressedImageUrl ? (
{renderImages ? (
<img
alt=""
aria-hidden="true"
className="device-chrome-button-image"
draggable={false}
src={
pressed && pressedImageUrl && !downCompositeUnder
? pressedImageUrl
: imageUrl
}
/>
) : null}
{renderImages && !pressed && pressedImageUrl ? (
<img
alt=""
aria-hidden="true"
Expand Down
3 changes: 3 additions & 0 deletions client/src/features/viewport/SimulatorViewport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ interface SimulatorViewportProps {
chromeProfile: ChromeProfile | null;
chromeLoaded: boolean;
chromeRequired: boolean;
chromeButtonsRenderedInChrome: boolean;
chromeScreenStyle: CSSProperties | null;
chromeUrl: string;
deviceFrameStyle: CSSProperties;
Expand Down Expand Up @@ -90,6 +91,7 @@ export function SimulatorViewport({
chromeProfile,
chromeLoaded,
chromeRequired,
chromeButtonsRenderedInChrome,
chromeScreenStyle,
chromeUrl,
deviceFrameStyle,
Expand Down Expand Up @@ -199,6 +201,7 @@ export function SimulatorViewport({
accessibilityRoots={accessibilityRoots}
accessibilitySelectedId={accessibilitySelectedId}
chromeProfile={chromeProfile}
chromeButtonsRenderedInChrome={chromeButtonsRenderedInChrome}
chromeScreenStyle={chromeScreenStyle}
chromeUrl={chromeUrl}
chromeButtonUrl={chromeButtonUrl}
Expand Down
7 changes: 7 additions & 0 deletions docs/api/rest.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ LAN browsers can pair with the printed six-digit code through:
POST /api/pair
```

Successful pairing sets the browser auth cookie and also returns the access token
for native clients:

```json
{ "ok": true, "accessToken": "<token>" }
```

## Quick Examples

```sh
Expand Down
7 changes: 7 additions & 0 deletions docs/cli/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Replace `simdeck` with `./build/simdeck` when running from a source checkout.
| `simdeck -k` | Stop the detached project daemon |
| `simdeck -r` | Restart the detached project daemon |
| `simdeck ui --open` | Open the browser UI from a daemon |
| `simdeck pair` | Show native iOS pairing code and QR |
| `simdeck daemon status` | Show daemon URL, PID, token, and log path |
| `simdeck daemon stop` | Stop the current project daemon |
| `simdeck daemon killall` | Stop all project daemons |
Expand All @@ -22,9 +23,15 @@ Examples:
```sh
simdeck ui --port 4320 --open
simdeck ui --open
simdeck pair
simdeck daemon restart --video-codec software --stream-quality low
```

`simdeck pair` uses the global LaunchAgent-backed service instead of a
project-local daemon. It binds the service for LAN access, preserves an existing
service token and pairing code when present, detects LAN and Tailscale IPv4
addresses, and prints a `simdeck://pair` QR for the native iOS app.

## Device Lifecycle

```sh
Expand Down
4 changes: 4 additions & 0 deletions docs/guide/lan-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,13 @@ Use an IP address or hostname that the remote device can resolve:
```sh
simdeck ui --bind 0.0.0.0 --advertise-host my-mac.local --open
simdeck ui --bind 0.0.0.0 --advertise-host 192.168.1.50 --open
simdeck ui --bind 0.0.0.0 --advertise-host 100.101.102.103 --open
```

If you bind to `0.0.0.0` but advertise `localhost`, remote browsers will try to connect to themselves.
Tailscale addresses work like direct HTTP hosts; discovery does not use LAN
broadcast across the tailnet, so use the Tailscale IP or MagicDNS name when
pairing a native client.

## Direct API Access

Expand Down
10 changes: 8 additions & 2 deletions docs/guide/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,12 +189,18 @@ Start SimDeck with a LAN bind and reachable advertised host:
simdeck ui --bind 0.0.0.0 --advertise-host 192.168.1.50 --open
```

For native iOS pairing, prefer:

```sh
simdeck pair
```

Then check:

- The remote browser opens `http://192.168.1.50:4310`.
- macOS Firewall allows the port.
- The pairing code matches the current daemon.
- API scripts send the daemon token.
- The pairing code matches the current daemon or global service.
- API scripts send the daemon or service token.

See [LAN Access](/guide/lan-access).

Expand Down
10 changes: 10 additions & 0 deletions ios/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# SimDeck Studio iOS

Native SwiftUI client for SimDeck live sessions.

- Opens LAN, Tailscale, and SimDeck Studio URLs.
- Uses the daemon's `/api/simulators/{udid}/webrtc/offer` endpoint and renders the H.264 WebRTC track with Metal.
- Sends touch and hardware controls over the `simdeck-control` WebRTC data channel.
- Supports `https://simdeck.djdev.me/simulator/{id}` links through Associated Domains and the `simdeck://` custom URL scheme.

Open `SimDeckStudio.xcodeproj`, select the `SimDeckStudio` scheme, and run on an iPhone or iPad target. The app display name is `SimDeck`.
Loading
Loading