Skip to content

Commit 94db823

Browse files
authored
Proxy Metro DevTools resources through SimDeck (#72)
* Proxy Metro DevTools resources through SimDeck * fix: format devtools panel
1 parent d6b4b20 commit 94db823

6 files changed

Lines changed: 712 additions & 50 deletions

File tree

docs/api/rest.md

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

341341
## DevTools and WebKit
342342

343-
| Method | Path | Purpose |
344-
| ------ | ----------------------------------------------------------- | --------------------------------------------------- |
345-
| `GET` | `/api/simulators/{udid}/webkit/targets` | Inspectable Safari or WKWebView targets |
346-
| `GET` | `/api/simulators/{udid}/webkit/targets/{targetId}/socket` | WebKit inspector WebSocket |
347-
| `GET` | `/webkit-inspector-ui/Main.html` | WebInspectorUI frontend |
348-
| `GET` | `/api/simulators/{udid}/devtools/targets` | React Native, app runtime, Metro, or Chrome targets |
349-
| `GET` | `/api/simulators/{udid}/devtools/targets/{targetId}/socket` | DevTools WebSocket |
350-
| `GET` | `/chrome-devtools-ui/inspector.html` | Chrome DevTools frontend |
343+
| Method | Path | Purpose |
344+
| ------------- | ----------------------------------------------------------- | --------------------------------------------------- |
345+
| `GET` | `/api/simulators/{udid}/webkit/targets` | Inspectable Safari or WKWebView targets |
346+
| `GET` | `/api/simulators/{udid}/webkit/targets/{targetId}/socket` | WebKit inspector WebSocket |
347+
| `GET` | `/webkit-inspector-ui/Main.html` | WebInspectorUI frontend |
348+
| `GET` | `/api/simulators/{udid}/devtools/targets` | React Native, app runtime, Metro, or Chrome targets |
349+
| `GET` | `/api/simulators/{udid}/devtools/targets/{targetId}/socket` | DevTools WebSocket |
350+
| `GET` | `/chrome-devtools-ui/inspector.html` | Chrome DevTools frontend |
351+
| `GET`, `POST` | `/api/metro/{port}/{path}` | Proxied Metro HTTP resources and DevTools frontend |
351352

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

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: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -853,9 +853,7 @@ 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 = shouldBlockDevToolsHostBrowser(selectedTarget);
859857
const safariAutoWaiting = Boolean(
860858
selectedTarget &&
861859
isSafariAutoTarget(selectedTarget) &&
@@ -1192,7 +1190,7 @@ export function resolveDevToolsTargetSelection({
11921190
pendingForegroundApp,
11931191
currentTargetId,
11941192
)
1195-
: isSafariForegroundApp(foregroundApp)
1193+
: foregroundApp
11961194
? highlyCompatibleTargetForForeground(
11971195
targets,
11981196
foregroundApp,
@@ -1310,6 +1308,15 @@ function foregroundCompatibilityScore(
13101308
score = Math.max(score, target.source === "React Native Metro" ? 98 : 90);
13111309
}
13121310

1311+
if (
1312+
foregroundAppName &&
1313+
(target.appName === foregroundAppName ||
1314+
target.title === foregroundAppName ||
1315+
target.title.startsWith(`${foregroundAppName}:`))
1316+
) {
1317+
score = Math.max(score, target.source === "React Native Metro" ? 94 : 86);
1318+
}
1319+
13131320
if (webKitMatchesForeground && target.appActive) {
13141321
score = Math.max(score, 93);
13151322
}
@@ -1534,6 +1541,27 @@ function isChromeTarget(target: DevToolsTarget): boolean {
15341541
return target.id.startsWith("chrome:");
15351542
}
15361543

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

packages/server/src/api/routes.rs

Lines changed: 74 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ use axum::extract::{ConnectInfo, DefaultBodyLimit, Path, Query, State};
2828
use axum::http::{header, HeaderMap, Method, Request, StatusCode, Uri};
2929
use axum::middleware::{from_fn_with_state, Next};
3030
use axum::response::{IntoResponse, Redirect, Response};
31-
use axum::routing::{get, post};
31+
use axum::routing::{any, get, post};
3232
use axum::{Json, Router};
3333
use bytes::{Bytes, BytesMut};
3434
use futures::{SinkExt, StreamExt};
@@ -778,10 +778,9 @@ pub fn router(state: AppState) -> Router {
778778
.route("/api/inspector/response", post(inspector_response))
779779
.route("/chrome-devtools-ui", get(chrome_devtools_ui_redirect))
780780
.route("/chrome-devtools-ui/{*path}", get(chrome_devtools_ui_file))
781-
.route(
782-
"/api/metro-frontend/{port}/{*path}",
783-
get(metro_frontend_asset),
784-
)
781+
.route("/api/metro/{port}", any(metro_proxy_root))
782+
.route("/api/metro/{port}/{*path}", any(metro_proxy_asset))
783+
.route("/api/metro-frontend/{port}/{*path}", any(metro_proxy_asset))
785784
.route("/webkit-inspector-ui", get(webkit_inspector_ui_redirect))
786785
.route(
787786
"/webkit-inspector-ui/{*path}",
@@ -1084,7 +1083,11 @@ async fn chrome_devtools_targets(
10841083
};
10851084
let foreground_app_future = timeout(
10861085
FOREGROUND_APP_ROUTE_TIMEOUT,
1087-
foreground_app_for_simulator(&state, &udid),
1086+
foreground_app_for_simulator_with_cache_ttl(
1087+
&state,
1088+
&udid,
1089+
INSPECTOR_FOREGROUND_APP_CACHE_TTL,
1090+
),
10881091
);
10891092
let external_targets_future = timeout(
10901093
CHROME_DEVTOOLS_DISCOVERY_TIMEOUT,
@@ -1230,24 +1233,77 @@ async fn webkit_inspector_ui_redirect() -> Redirect {
12301233
Redirect::temporary("/webkit-inspector-ui/Main.html")
12311234
}
12321235

1233-
async fn metro_frontend_asset(Path((port, path)): Path<(u16, String)>, uri: Uri) -> Response {
1234-
let asset_path = format!("/{path}");
1235-
match devtools::fetch_metro_frontend_asset(port, &asset_path, uri.query()).await {
1236+
async fn metro_proxy_root(
1237+
Path(port): Path<u16>,
1238+
method: Method,
1239+
headers: HeaderMap,
1240+
uri: Uri,
1241+
body: Bytes,
1242+
) -> Response {
1243+
metro_proxy_response(port, "/", uri.query(), method, headers, body).await
1244+
}
1245+
1246+
async fn metro_proxy_asset(
1247+
Path((port, path)): Path<(u16, String)>,
1248+
method: Method,
1249+
headers: HeaderMap,
1250+
uri: Uri,
1251+
body: Bytes,
1252+
) -> Response {
1253+
metro_proxy_response(
1254+
port,
1255+
&format!("/{path}"),
1256+
uri.query(),
1257+
method,
1258+
headers,
1259+
body,
1260+
)
1261+
.await
1262+
}
1263+
1264+
async fn metro_proxy_response(
1265+
port: u16,
1266+
asset_path: &str,
1267+
query: Option<&str>,
1268+
method: Method,
1269+
headers: HeaderMap,
1270+
body: Bytes,
1271+
) -> Response {
1272+
let content_type = headers
1273+
.get(header::CONTENT_TYPE)
1274+
.and_then(|value| value.to_str().ok());
1275+
match devtools::fetch_metro_resource(
1276+
port,
1277+
asset_path,
1278+
query,
1279+
method.as_str(),
1280+
Some(body.as_ref()),
1281+
content_type,
1282+
)
1283+
.await
1284+
{
12361285
Ok(asset) => {
12371286
let status = StatusCode::from_u16(asset.status).unwrap_or(StatusCode::BAD_GATEWAY);
1287+
let body = devtools::rewrite_metro_proxy_asset(
1288+
port,
1289+
asset_path,
1290+
asset.content_type.as_deref(),
1291+
asset.body,
1292+
);
12381293
let mut builder = Response::builder()
12391294
.status(status)
12401295
.header(header::CACHE_CONTROL, "no-store");
12411296
if let Some(content_type) = asset.content_type {
12421297
builder = builder.header(header::CONTENT_TYPE, content_type);
12431298
}
12441299
builder
1245-
.body(Body::from(asset.body))
1300+
.body(Body::from(body))
12461301
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
12471302
}
12481303
Err(error) => {
1249-
tracing::debug!("Metro frontend asset proxy failed for {port}{asset_path}: {error}");
1250-
AppError::not_found("Metro DevTools frontend asset is not available.").into_response()
1304+
tracing::debug!("Metro proxy failed for {port}{asset_path}: {error}");
1305+
AppError::not_found("Metro resource is not available through the proxy.")
1306+
.into_response()
12511307
}
12521308
}
12531309
}
@@ -4642,7 +4698,11 @@ async fn foreground_app_for_simulator_with_cache_ttl(
46424698
}
46434699

46444700
let mut last_error: Option<String> = None;
4645-
match foreground_app_from_launchctl(udid).await {
4701+
4702+
// DevTools selection needs the app currently under the private display.
4703+
// launchctl can leave recently active UIKit services marked as active, so
4704+
// prefer the frontmost accessibility root when it is available.
4705+
match foreground_app_metadata(state, udid).await {
46464706
Ok(Some(foreground)) => {
46474707
cache_foreground_app(udid, &foreground);
46484708
return Ok(Some(foreground));
@@ -4651,7 +4711,7 @@ async fn foreground_app_for_simulator_with_cache_ttl(
46514711
Err(error) => last_error = Some(error),
46524712
}
46534713

4654-
match foreground_app_metadata(state, udid).await {
4714+
match foreground_app_from_launchctl(udid).await {
46554715
Ok(Some(foreground)) => {
46564716
cache_foreground_app(udid, &foreground);
46574717
Ok(Some(foreground))

0 commit comments

Comments
 (0)