Skip to content

Commit 9d04ceb

Browse files
committed
Proxy Metro DevTools resources through SimDeck
1 parent 9008123 commit 9d04ceb

6 files changed

Lines changed: 713 additions & 50 deletions

File tree

docs/api/rest.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -311,14 +311,15 @@ positive checks. `assertNot` performs negative checks.
311311

312312
## DevTools and WebKit
313313

314-
| Method | Path | Purpose |
315-
| ------ | ----------------------------------------------------------- | --------------------------------------------------- |
316-
| `GET` | `/api/simulators/{udid}/webkit/targets` | Inspectable Safari or WKWebView targets |
317-
| `GET` | `/api/simulators/{udid}/webkit/targets/{targetId}/socket` | WebKit inspector WebSocket |
318-
| `GET` | `/webkit-inspector-ui/Main.html` | WebInspectorUI frontend |
319-
| `GET` | `/api/simulators/{udid}/devtools/targets` | React Native, app runtime, Metro, or Chrome targets |
320-
| `GET` | `/api/simulators/{udid}/devtools/targets/{targetId}/socket` | DevTools WebSocket |
321-
| `GET` | `/chrome-devtools-ui/inspector.html` | Chrome DevTools frontend |
314+
| Method | Path | Purpose |
315+
| ------------- | ----------------------------------------------------------- | --------------------------------------------------- |
316+
| `GET` | `/api/simulators/{udid}/webkit/targets` | Inspectable Safari or WKWebView targets |
317+
| `GET` | `/api/simulators/{udid}/webkit/targets/{targetId}/socket` | WebKit inspector WebSocket |
318+
| `GET` | `/webkit-inspector-ui/Main.html` | WebInspectorUI frontend |
319+
| `GET` | `/api/simulators/{udid}/devtools/targets` | React Native, app runtime, Metro, or Chrome targets |
320+
| `GET` | `/api/simulators/{udid}/devtools/targets/{targetId}/socket` | DevTools WebSocket |
321+
| `GET` | `/chrome-devtools-ui/inspector.html` | Chrome DevTools frontend |
322+
| `GET`, `POST` | `/api/metro/{port}/{path}` | Proxied Metro HTTP resources and DevTools frontend |
322323

323324
For app-owned `WKWebView` on iOS 16.4 or newer, the app must set `isInspectable = true`.
324325

docs/inspector/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ Open the SimDeck UI and select a device. The inspector pane shows the active tre
3535
The DevTools panel can open:
3636

3737
- Safari and inspectable `WKWebView` targets.
38-
- React Native Metro targets.
38+
- React Native Metro targets through Metro's own proxied DevTools frontend.
3939
- Local Chrome Inspector targets.
4040
- Connected app runtime inspector targets.
4141

packages/client/src/features/devtools/DevToolsPanel.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
22

33
import {
44
resolveDevToolsTargetSelection,
5+
shouldBlockDevToolsHostBrowser,
56
shouldRemountWebKitFrameForHealth,
67
withSafariAutoTarget,
78
type DevToolsTarget,
@@ -162,6 +163,52 @@ describe("resolveDevToolsTargetSelection", () => {
162163
targetId: first.id,
163164
});
164165
});
166+
167+
it("selects a React Native Metro target for the foreground app instead of Safari auto", () => {
168+
const safari = safariTarget("webkit:active", "https://metro.example/", {
169+
pageActive: true,
170+
});
171+
const metroTarget: DevToolsTarget = {
172+
appName: "Example",
173+
bundleIdentifier: "com.example.app",
174+
frameUrl: "/api/metro/8081/debugger-frontend/rn_fusebox.html",
175+
id: "chrome:metro-8081-example",
176+
meta: "com.example.app",
177+
processIdentifier: 0,
178+
source: "React Native Metro",
179+
title: "Example",
180+
};
181+
const runtimeTarget: DevToolsTarget = {
182+
appName: "Example",
183+
bundleIdentifier: "com.example.app",
184+
frameUrl: "/chrome-devtools-ui/inspector.html",
185+
id: "chrome:sdi-123",
186+
meta: "com.example.app",
187+
processIdentifier: 123,
188+
source: "React Native",
189+
title: "Example",
190+
};
191+
const targets = withSafariAutoTarget([safari, runtimeTarget, metroTarget]);
192+
193+
expect(
194+
resolveDevToolsTargetSelection({
195+
currentForegroundKey: "com.example.app",
196+
currentTargetId: "webkit:safari:auto",
197+
foregroundApp: {
198+
appName: "Example",
199+
bundleIdentifier: "com.example.app",
200+
processIdentifier: 123,
201+
},
202+
manualOverride: false,
203+
pendingForegroundApp: null,
204+
pendingForegroundKey: "",
205+
targets,
206+
}),
207+
).toMatchObject({
208+
automaticTargetId: metroTarget.id,
209+
targetId: metroTarget.id,
210+
});
211+
});
165212
});
166213

167214
describe("shouldRemountWebKitFrameForHealth", () => {
@@ -221,3 +268,54 @@ describe("shouldRemountWebKitFrameForHealth", () => {
221268
).toBe(false);
222269
});
223270
});
271+
272+
describe("shouldBlockDevToolsHostBrowser", () => {
273+
it("allows Metro/Rozenite DevTools in Safari", () => {
274+
expect(
275+
shouldBlockDevToolsHostBrowser(
276+
{
277+
appName: "Rozenite",
278+
bundleIdentifier: "com.callstackcincubator.rozenite",
279+
frameUrl: "/api/metro/8091/rozenite/rn_fusebox.html",
280+
id: "chrome:metro-8091-rozenite",
281+
meta: "com.callstackcincubator.rozenite",
282+
source: "React Native Metro",
283+
title: "Rozenite",
284+
},
285+
true,
286+
),
287+
).toBe(false);
288+
});
289+
290+
it("still blocks non-Metro Chrome DevTools in Safari", () => {
291+
expect(
292+
shouldBlockDevToolsHostBrowser(
293+
{
294+
appName: "Chrome",
295+
frameUrl: "/chrome-devtools-ui/inspector.html",
296+
id: "chrome:cdp-9222-page",
297+
meta: "http://localhost:3000",
298+
source: "Chrome Inspector",
299+
title: "Localhost",
300+
},
301+
true,
302+
),
303+
).toBe(true);
304+
});
305+
306+
it("allows Chrome DevTools targets outside Safari hosts", () => {
307+
expect(
308+
shouldBlockDevToolsHostBrowser(
309+
{
310+
appName: "Chrome",
311+
frameUrl: "/chrome-devtools-ui/inspector.html",
312+
id: "chrome:cdp-9222-page",
313+
meta: "http://localhost:3000",
314+
source: "Chrome Inspector",
315+
title: "Localhost",
316+
},
317+
false,
318+
),
319+
).toBe(false);
320+
});
321+
});

packages/client/src/features/devtools/DevToolsPanel.tsx

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -853,9 +853,8 @@ export function DevToolsPanel({
853853
(isLoading || isWebKitLoading || isHoldingEmptyDiscovery);
854854
const effectivelyDisconnected =
855855
disconnected || error === NOT_CONNECTED_MESSAGE;
856-
const chromeDevToolsBlocked = Boolean(
857-
selectedTarget && isChromeTarget(selectedTarget) && isSafariBrowser(),
858-
);
856+
const chromeDevToolsBlocked =
857+
shouldBlockDevToolsHostBrowser(selectedTarget);
859858
const safariAutoWaiting = Boolean(
860859
selectedTarget &&
861860
isSafariAutoTarget(selectedTarget) &&
@@ -1192,7 +1191,7 @@ export function resolveDevToolsTargetSelection({
11921191
pendingForegroundApp,
11931192
currentTargetId,
11941193
)
1195-
: isSafariForegroundApp(foregroundApp)
1194+
: foregroundApp
11961195
? highlyCompatibleTargetForForeground(
11971196
targets,
11981197
foregroundApp,
@@ -1310,6 +1309,15 @@ function foregroundCompatibilityScore(
13101309
score = Math.max(score, target.source === "React Native Metro" ? 98 : 90);
13111310
}
13121311

1312+
if (
1313+
foregroundAppName &&
1314+
(target.appName === foregroundAppName ||
1315+
target.title === foregroundAppName ||
1316+
target.title.startsWith(`${foregroundAppName}:`))
1317+
) {
1318+
score = Math.max(score, target.source === "React Native Metro" ? 94 : 86);
1319+
}
1320+
13131321
if (webKitMatchesForeground && target.appActive) {
13141322
score = Math.max(score, 93);
13151323
}
@@ -1534,6 +1542,27 @@ function isChromeTarget(target: DevToolsTarget): boolean {
15341542
return target.id.startsWith("chrome:");
15351543
}
15361544

1545+
export function shouldBlockDevToolsHostBrowser(
1546+
target: DevToolsTarget | null,
1547+
safariHostBrowser = isSafariBrowser(),
1548+
): boolean {
1549+
return Boolean(
1550+
target &&
1551+
safariHostBrowser &&
1552+
isChromeTarget(target) &&
1553+
!isMetroDevToolsTarget(target),
1554+
);
1555+
}
1556+
1557+
function isMetroDevToolsTarget(target: DevToolsTarget): boolean {
1558+
return (
1559+
target.source === "React Native Metro" ||
1560+
target.frameUrl.includes("/api/metro/") ||
1561+
target.frameUrl.includes("/rozenite/") ||
1562+
target.frameUrl.includes("/debugger-frontend/")
1563+
);
1564+
}
1565+
15371566
function isSafariAutoTarget(target: DevToolsTarget): boolean {
15381567
return target.safariAuto === true || target.id === SAFARI_AUTO_TARGET_ID;
15391568
}

packages/server/src/api/routes.rs

Lines changed: 74 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ use axum::extract::{ConnectInfo, DefaultBodyLimit, Path, Query, State};
2727
use axum::http::{header, HeaderMap, Method, Request, StatusCode, Uri};
2828
use axum::middleware::{from_fn_with_state, Next};
2929
use axum::response::{IntoResponse, Redirect, Response};
30-
use axum::routing::{get, post};
30+
use axum::routing::{any, get, post};
3131
use axum::{Json, Router};
3232
use bytes::{Bytes, BytesMut};
3333
use futures::{SinkExt, StreamExt};
@@ -777,10 +777,9 @@ pub fn router(state: AppState) -> Router {
777777
.route("/api/inspector/response", post(inspector_response))
778778
.route("/chrome-devtools-ui", get(chrome_devtools_ui_redirect))
779779
.route("/chrome-devtools-ui/{*path}", get(chrome_devtools_ui_file))
780-
.route(
781-
"/api/metro-frontend/{port}/{*path}",
782-
get(metro_frontend_asset),
783-
)
780+
.route("/api/metro/{port}", any(metro_proxy_root))
781+
.route("/api/metro/{port}/{*path}", any(metro_proxy_asset))
782+
.route("/api/metro-frontend/{port}/{*path}", any(metro_proxy_asset))
784783
.route("/webkit-inspector-ui", get(webkit_inspector_ui_redirect))
785784
.route(
786785
"/webkit-inspector-ui/{*path}",
@@ -1074,7 +1073,11 @@ async fn chrome_devtools_targets(
10741073
};
10751074
let foreground_app_future = timeout(
10761075
FOREGROUND_APP_ROUTE_TIMEOUT,
1077-
foreground_app_for_simulator(&state, &udid),
1076+
foreground_app_for_simulator_with_cache_ttl(
1077+
&state,
1078+
&udid,
1079+
INSPECTOR_FOREGROUND_APP_CACHE_TTL,
1080+
),
10781081
);
10791082
let external_targets_future = timeout(
10801083
CHROME_DEVTOOLS_DISCOVERY_TIMEOUT,
@@ -1220,24 +1223,77 @@ async fn webkit_inspector_ui_redirect() -> Redirect {
12201223
Redirect::temporary("/webkit-inspector-ui/Main.html")
12211224
}
12221225

1223-
async fn metro_frontend_asset(Path((port, path)): Path<(u16, String)>, uri: Uri) -> Response {
1224-
let asset_path = format!("/{path}");
1225-
match devtools::fetch_metro_frontend_asset(port, &asset_path, uri.query()).await {
1226+
async fn metro_proxy_root(
1227+
Path(port): Path<u16>,
1228+
method: Method,
1229+
headers: HeaderMap,
1230+
uri: Uri,
1231+
body: Bytes,
1232+
) -> Response {
1233+
metro_proxy_response(port, "/", uri.query(), method, headers, body).await
1234+
}
1235+
1236+
async fn metro_proxy_asset(
1237+
Path((port, path)): Path<(u16, String)>,
1238+
method: Method,
1239+
headers: HeaderMap,
1240+
uri: Uri,
1241+
body: Bytes,
1242+
) -> Response {
1243+
metro_proxy_response(
1244+
port,
1245+
&format!("/{path}"),
1246+
uri.query(),
1247+
method,
1248+
headers,
1249+
body,
1250+
)
1251+
.await
1252+
}
1253+
1254+
async fn metro_proxy_response(
1255+
port: u16,
1256+
asset_path: &str,
1257+
query: Option<&str>,
1258+
method: Method,
1259+
headers: HeaderMap,
1260+
body: Bytes,
1261+
) -> Response {
1262+
let content_type = headers
1263+
.get(header::CONTENT_TYPE)
1264+
.and_then(|value| value.to_str().ok());
1265+
match devtools::fetch_metro_resource(
1266+
port,
1267+
asset_path,
1268+
query,
1269+
method.as_str(),
1270+
Some(body.as_ref()),
1271+
content_type,
1272+
)
1273+
.await
1274+
{
12261275
Ok(asset) => {
12271276
let status = StatusCode::from_u16(asset.status).unwrap_or(StatusCode::BAD_GATEWAY);
1277+
let body = devtools::rewrite_metro_proxy_asset(
1278+
port,
1279+
asset_path,
1280+
asset.content_type.as_deref(),
1281+
asset.body,
1282+
);
12281283
let mut builder = Response::builder()
12291284
.status(status)
12301285
.header(header::CACHE_CONTROL, "no-store");
12311286
if let Some(content_type) = asset.content_type {
12321287
builder = builder.header(header::CONTENT_TYPE, content_type);
12331288
}
12341289
builder
1235-
.body(Body::from(asset.body))
1290+
.body(Body::from(body))
12361291
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
12371292
}
12381293
Err(error) => {
1239-
tracing::debug!("Metro frontend asset proxy failed for {port}{asset_path}: {error}");
1240-
AppError::not_found("Metro DevTools frontend asset is not available.").into_response()
1294+
tracing::debug!("Metro proxy failed for {port}{asset_path}: {error}");
1295+
AppError::not_found("Metro resource is not available through the proxy.")
1296+
.into_response()
12411297
}
12421298
}
12431299
}
@@ -4563,7 +4619,11 @@ async fn foreground_app_for_simulator_with_cache_ttl(
45634619
}
45644620

45654621
let mut last_error: Option<String> = None;
4566-
match foreground_app_from_launchctl(udid).await {
4622+
4623+
// DevTools selection needs the app currently under the private display.
4624+
// launchctl can leave recently active UIKit services marked as active, so
4625+
// prefer the frontmost accessibility root when it is available.
4626+
match foreground_app_metadata(state, udid).await {
45674627
Ok(Some(foreground)) => {
45684628
cache_foreground_app(udid, &foreground);
45694629
return Ok(Some(foreground));
@@ -4572,7 +4632,7 @@ async fn foreground_app_for_simulator_with_cache_ttl(
45724632
Err(error) => last_error = Some(error),
45734633
}
45744634

4575-
match foreground_app_metadata(state, udid).await {
4635+
match foreground_app_from_launchctl(udid).await {
45764636
Ok(Some(foreground)) => {
45774637
cache_foreground_app(udid, &foreground);
45784638
Ok(Some(foreground))

0 commit comments

Comments
 (0)