Skip to content
Merged
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
17 changes: 9 additions & 8 deletions docs/api/rest.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,14 +311,15 @@ positive checks. `assertNot` performs negative checks.

## DevTools and WebKit

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

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

Expand Down
2 changes: 1 addition & 1 deletion docs/inspector/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ Open the SimDeck UI and select a device. The inspector pane shows the active tre
The DevTools panel can open:

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

Expand Down
98 changes: 98 additions & 0 deletions packages/client/src/features/devtools/DevToolsPanel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";

import {
resolveDevToolsTargetSelection,
shouldBlockDevToolsHostBrowser,
shouldRemountWebKitFrameForHealth,
withSafariAutoTarget,
type DevToolsTarget,
Expand Down Expand Up @@ -162,6 +163,52 @@ describe("resolveDevToolsTargetSelection", () => {
targetId: first.id,
});
});

it("selects a React Native Metro target for the foreground app instead of Safari auto", () => {
const safari = safariTarget("webkit:active", "https://metro.example/", {
pageActive: true,
});
const metroTarget: DevToolsTarget = {
appName: "Example",
bundleIdentifier: "com.example.app",
frameUrl: "/api/metro/8081/debugger-frontend/rn_fusebox.html",
id: "chrome:metro-8081-example",
meta: "com.example.app",
processIdentifier: 0,
source: "React Native Metro",
title: "Example",
};
const runtimeTarget: DevToolsTarget = {
appName: "Example",
bundleIdentifier: "com.example.app",
frameUrl: "/chrome-devtools-ui/inspector.html",
id: "chrome:sdi-123",
meta: "com.example.app",
processIdentifier: 123,
source: "React Native",
title: "Example",
};
const targets = withSafariAutoTarget([safari, runtimeTarget, metroTarget]);

expect(
resolveDevToolsTargetSelection({
currentForegroundKey: "com.example.app",
currentTargetId: "webkit:safari:auto",
foregroundApp: {
appName: "Example",
bundleIdentifier: "com.example.app",
processIdentifier: 123,
},
manualOverride: false,
pendingForegroundApp: null,
pendingForegroundKey: "",
targets,
}),
).toMatchObject({
automaticTargetId: metroTarget.id,
targetId: metroTarget.id,
});
});
});

describe("shouldRemountWebKitFrameForHealth", () => {
Expand Down Expand Up @@ -221,3 +268,54 @@ describe("shouldRemountWebKitFrameForHealth", () => {
).toBe(false);
});
});

describe("shouldBlockDevToolsHostBrowser", () => {
it("allows Metro/Rozenite DevTools in Safari", () => {
expect(
shouldBlockDevToolsHostBrowser(
{
appName: "Rozenite",
bundleIdentifier: "com.callstackcincubator.rozenite",
frameUrl: "/api/metro/8091/rozenite/rn_fusebox.html",
id: "chrome:metro-8091-rozenite",
meta: "com.callstackcincubator.rozenite",
source: "React Native Metro",
title: "Rozenite",
},
true,
),
).toBe(false);
});

it("still blocks non-Metro Chrome DevTools in Safari", () => {
expect(
shouldBlockDevToolsHostBrowser(
{
appName: "Chrome",
frameUrl: "/chrome-devtools-ui/inspector.html",
id: "chrome:cdp-9222-page",
meta: "http://localhost:3000",
source: "Chrome Inspector",
title: "Localhost",
},
true,
),
).toBe(true);
});

it("allows Chrome DevTools targets outside Safari hosts", () => {
expect(
shouldBlockDevToolsHostBrowser(
{
appName: "Chrome",
frameUrl: "/chrome-devtools-ui/inspector.html",
id: "chrome:cdp-9222-page",
meta: "http://localhost:3000",
source: "Chrome Inspector",
title: "Localhost",
},
false,
),
).toBe(false);
});
});
36 changes: 32 additions & 4 deletions packages/client/src/features/devtools/DevToolsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -853,9 +853,7 @@ export function DevToolsPanel({
(isLoading || isWebKitLoading || isHoldingEmptyDiscovery);
const effectivelyDisconnected =
disconnected || error === NOT_CONNECTED_MESSAGE;
const chromeDevToolsBlocked = Boolean(
selectedTarget && isChromeTarget(selectedTarget) && isSafariBrowser(),
);
const chromeDevToolsBlocked = shouldBlockDevToolsHostBrowser(selectedTarget);
const safariAutoWaiting = Boolean(
selectedTarget &&
isSafariAutoTarget(selectedTarget) &&
Expand Down Expand Up @@ -1192,7 +1190,7 @@ export function resolveDevToolsTargetSelection({
pendingForegroundApp,
currentTargetId,
)
: isSafariForegroundApp(foregroundApp)
: foregroundApp
? highlyCompatibleTargetForForeground(
targets,
foregroundApp,
Expand Down Expand Up @@ -1310,6 +1308,15 @@ function foregroundCompatibilityScore(
score = Math.max(score, target.source === "React Native Metro" ? 98 : 90);
}

if (
foregroundAppName &&
(target.appName === foregroundAppName ||
target.title === foregroundAppName ||
target.title.startsWith(`${foregroundAppName}:`))
) {
score = Math.max(score, target.source === "React Native Metro" ? 94 : 86);
}

if (webKitMatchesForeground && target.appActive) {
score = Math.max(score, 93);
}
Expand Down Expand Up @@ -1534,6 +1541,27 @@ function isChromeTarget(target: DevToolsTarget): boolean {
return target.id.startsWith("chrome:");
}

export function shouldBlockDevToolsHostBrowser(
target: DevToolsTarget | null,
safariHostBrowser = isSafariBrowser(),
): boolean {
return Boolean(
target &&
safariHostBrowser &&
isChromeTarget(target) &&
!isMetroDevToolsTarget(target),
);
}

function isMetroDevToolsTarget(target: DevToolsTarget): boolean {
return (
target.source === "React Native Metro" ||
target.frameUrl.includes("/api/metro/") ||
target.frameUrl.includes("/rozenite/") ||
target.frameUrl.includes("/debugger-frontend/")
);
}

function isSafariAutoTarget(target: DevToolsTarget): boolean {
return target.safariAuto === true || target.id === SAFARI_AUTO_TARGET_ID;
}
Expand Down
88 changes: 74 additions & 14 deletions packages/server/src/api/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ use axum::extract::{ConnectInfo, DefaultBodyLimit, Path, Query, State};
use axum::http::{header, HeaderMap, Method, Request, StatusCode, Uri};
use axum::middleware::{from_fn_with_state, Next};
use axum::response::{IntoResponse, Redirect, Response};
use axum::routing::{get, post};
use axum::routing::{any, get, post};
use axum::{Json, Router};
use bytes::{Bytes, BytesMut};
use futures::{SinkExt, StreamExt};
Expand Down Expand Up @@ -777,10 +777,9 @@ pub fn router(state: AppState) -> Router {
.route("/api/inspector/response", post(inspector_response))
.route("/chrome-devtools-ui", get(chrome_devtools_ui_redirect))
.route("/chrome-devtools-ui/{*path}", get(chrome_devtools_ui_file))
.route(
"/api/metro-frontend/{port}/{*path}",
get(metro_frontend_asset),
)
.route("/api/metro/{port}", any(metro_proxy_root))
.route("/api/metro/{port}/{*path}", any(metro_proxy_asset))
.route("/api/metro-frontend/{port}/{*path}", any(metro_proxy_asset))
.route("/webkit-inspector-ui", get(webkit_inspector_ui_redirect))
.route(
"/webkit-inspector-ui/{*path}",
Expand Down Expand Up @@ -1074,7 +1073,11 @@ async fn chrome_devtools_targets(
};
let foreground_app_future = timeout(
FOREGROUND_APP_ROUTE_TIMEOUT,
foreground_app_for_simulator(&state, &udid),
foreground_app_for_simulator_with_cache_ttl(
&state,
&udid,
INSPECTOR_FOREGROUND_APP_CACHE_TTL,
),
);
let external_targets_future = timeout(
CHROME_DEVTOOLS_DISCOVERY_TIMEOUT,
Expand Down Expand Up @@ -1220,24 +1223,77 @@ async fn webkit_inspector_ui_redirect() -> Redirect {
Redirect::temporary("/webkit-inspector-ui/Main.html")
}

async fn metro_frontend_asset(Path((port, path)): Path<(u16, String)>, uri: Uri) -> Response {
let asset_path = format!("/{path}");
match devtools::fetch_metro_frontend_asset(port, &asset_path, uri.query()).await {
async fn metro_proxy_root(
Path(port): Path<u16>,
method: Method,
headers: HeaderMap,
uri: Uri,
body: Bytes,
) -> Response {
metro_proxy_response(port, "/", uri.query(), method, headers, body).await
}

async fn metro_proxy_asset(
Path((port, path)): Path<(u16, String)>,
method: Method,
headers: HeaderMap,
uri: Uri,
body: Bytes,
) -> Response {
metro_proxy_response(
port,
&format!("/{path}"),
uri.query(),
method,
headers,
body,
)
.await
}

async fn metro_proxy_response(
port: u16,
asset_path: &str,
query: Option<&str>,
method: Method,
headers: HeaderMap,
body: Bytes,
) -> Response {
let content_type = headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok());
match devtools::fetch_metro_resource(
port,
asset_path,
query,
method.as_str(),
Some(body.as_ref()),
content_type,
)
.await
{
Ok(asset) => {
let status = StatusCode::from_u16(asset.status).unwrap_or(StatusCode::BAD_GATEWAY);
let body = devtools::rewrite_metro_proxy_asset(
port,
asset_path,
asset.content_type.as_deref(),
asset.body,
);
let mut builder = Response::builder()
.status(status)
.header(header::CACHE_CONTROL, "no-store");
if let Some(content_type) = asset.content_type {
builder = builder.header(header::CONTENT_TYPE, content_type);
}
builder
.body(Body::from(asset.body))
.body(Body::from(body))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
Err(error) => {
tracing::debug!("Metro frontend asset proxy failed for {port}{asset_path}: {error}");
AppError::not_found("Metro DevTools frontend asset is not available.").into_response()
tracing::debug!("Metro proxy failed for {port}{asset_path}: {error}");
AppError::not_found("Metro resource is not available through the proxy.")
.into_response()
}
}
}
Expand Down Expand Up @@ -4563,7 +4619,11 @@ async fn foreground_app_for_simulator_with_cache_ttl(
}

let mut last_error: Option<String> = None;
match foreground_app_from_launchctl(udid).await {

// DevTools selection needs the app currently under the private display.
// launchctl can leave recently active UIKit services marked as active, so
// prefer the frontmost accessibility root when it is available.
match foreground_app_metadata(state, udid).await {
Ok(Some(foreground)) => {
cache_foreground_app(udid, &foreground);
return Ok(Some(foreground));
Expand All @@ -4572,7 +4632,7 @@ async fn foreground_app_for_simulator_with_cache_ttl(
Err(error) => last_error = Some(error),
}

match foreground_app_metadata(state, udid).await {
match foreground_app_from_launchctl(udid).await {
Ok(Some(foreground)) => {
cache_foreground_app(udid, &foreground);
Ok(Some(foreground))
Expand Down
Loading
Loading