diff --git a/docs/api/rest.md b/docs/api/rest.md index 87449357..9ef81844 100644 --- a/docs/api/rest.md +++ b/docs/api/rest.md @@ -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`. diff --git a/docs/inspector/index.md b/docs/inspector/index.md index a89dd73f..9c6764ae 100644 --- a/docs/inspector/index.md +++ b/docs/inspector/index.md @@ -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. diff --git a/packages/client/src/features/devtools/DevToolsPanel.test.ts b/packages/client/src/features/devtools/DevToolsPanel.test.ts index 154555da..472c7a76 100644 --- a/packages/client/src/features/devtools/DevToolsPanel.test.ts +++ b/packages/client/src/features/devtools/DevToolsPanel.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { resolveDevToolsTargetSelection, + shouldBlockDevToolsHostBrowser, shouldRemountWebKitFrameForHealth, withSafariAutoTarget, type DevToolsTarget, @@ -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", () => { @@ -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); + }); +}); diff --git a/packages/client/src/features/devtools/DevToolsPanel.tsx b/packages/client/src/features/devtools/DevToolsPanel.tsx index afce90a5..a56e3af6 100644 --- a/packages/client/src/features/devtools/DevToolsPanel.tsx +++ b/packages/client/src/features/devtools/DevToolsPanel.tsx @@ -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) && @@ -1192,7 +1190,7 @@ export function resolveDevToolsTargetSelection({ pendingForegroundApp, currentTargetId, ) - : isSafariForegroundApp(foregroundApp) + : foregroundApp ? highlyCompatibleTargetForForeground( targets, foregroundApp, @@ -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); } @@ -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; } diff --git a/packages/server/src/api/routes.rs b/packages/server/src/api/routes.rs index 31751f1c..31db4551 100644 --- a/packages/server/src/api/routes.rs +++ b/packages/server/src/api/routes.rs @@ -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}; @@ -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}", @@ -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, @@ -1220,11 +1223,63 @@ 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, + 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"); @@ -1232,12 +1287,13 @@ async fn metro_frontend_asset(Path((port, path)): Path<(u16, String)>, uri: Uri) 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() } } } @@ -4563,7 +4619,11 @@ async fn foreground_app_for_simulator_with_cache_ttl( } let mut last_error: Option = 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)); @@ -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)) diff --git a/packages/server/src/devtools.rs b/packages/server/src/devtools.rs index 47fe30b1..d48782ef 100644 --- a/packages/server/src/devtools.rs +++ b/packages/server/src/devtools.rs @@ -496,6 +496,26 @@ async fn fetch_devtools_json(host: &str, port: u16, path: &str) -> Result Result<(String, TcpStream), String> { + let mut errors = Vec::new(); + for host in DEVTOOLS_DISCOVERY_HOSTS { + let address = format!("{host}:{port}"); + match timeout(timeout_duration, TcpStream::connect(&address)).await { + Ok(Ok(stream)) => return Ok((address, stream)), + Ok(Err(error)) => errors.push(format!("{address}: {error}")), + Err(_) => errors.push(format!("{address}: timed out")), + } + } + Err(format!( + "Unable to connect to {service_name} on loopback port {port}: {}", + errors.join("; ") + )) +} + async fn candidate_devtools_ports() -> Vec { let mut ports = BTreeSet::new(); ports.extend(COMMON_METRO_PORTS.iter().copied()); @@ -763,7 +783,7 @@ fn metro_devtools_frontend_url(port: u16, entry: &Value, web_socket_debugger_url } fn metro_frontend_proxy_base(port: u16, asset_path: &str) -> String { - format!("/api/metro-frontend/{port}{asset_path}") + format!("/api/metro/{port}{asset_path}") } fn metro_frontend_asset_path(frontend: Option<&str>) -> String { @@ -793,6 +813,19 @@ pub fn is_metro_frontend_path(path: &str) -> bool { path.starts_with("/debugger-frontend/") || path.starts_with("/rozenite/") } +pub fn is_metro_proxy_path(path: &str) -> bool { + path.starts_with('/') + && !path.starts_with("//") + && !path + .bytes() + .any(|byte| byte < 0x20 || byte == 0x7f || byte == b'\\') + && !path.split('/').any(|part| part == "..") +} + +pub fn is_metro_proxy_method(method: &str) -> bool { + matches!(method, "GET" | "HEAD" | "POST") +} + fn metro_frontend_query_with_socket(query: Option<&str>, web_socket_debugger_url: &str) -> String { let socket_param = web_socket_debugger_url .trim_start_matches("ws://") @@ -802,6 +835,7 @@ fn metro_frontend_query_with_socket(query: Option<&str>, web_socket_debugger_url percent_encode_query_component(socket_param) )]; if let Some(query) = query { + let metadata_params = metro_frontend_metadata_params(query); params.extend( query .split('&') @@ -810,10 +844,56 @@ fn metro_frontend_query_with_socket(query: Option<&str>, web_socket_debugger_url }) .map(ToOwned::to_owned), ); + for (key, param) in metadata_params { + if !query_has_param(query, &key) { + params.push(param); + } + } } params.join("&") } +fn metro_frontend_metadata_params(query: &str) -> Vec<(String, String)> { + query + .split('&') + .filter_map(|param| { + let (key, value) = param.split_once('=')?; + matches!(key, "ws" | "wss").then_some(value) + }) + .flat_map(|value| { + let decoded = percent_decode_query_component(value); + split_path_query(&decoded) + .1 + .map(metro_frontend_inspector_params) + .unwrap_or_default() + }) + .collect() +} + +fn metro_frontend_inspector_params(query: &str) -> Vec<(String, String)> { + query + .split('&') + .filter_map(|param| { + let (key, value) = param.split_once('=')?; + matches!(key, "device" | "page").then(|| { + ( + key.to_owned(), + format!("{key}={}", percent_encode_query_component(value)), + ) + }) + }) + .collect() +} + +fn query_has_param(query: &str, needle: &str) -> bool { + query.split('&').any(|param| { + param + .split_once('=') + .map(|(key, _)| key == needle) + .unwrap_or(false) + }) +} + fn websocket_path_with_access_token(path: String, access_token: Option<&str>) -> String { let Some(access_token) = access_token .map(str::trim) @@ -828,33 +908,50 @@ fn websocket_path_with_access_token(path: String, access_token: Option<&str>) -> ) } -/// Reverse-proxies a single Metro DevTools frontend asset (`/debugger-frontend/*` -/// or `/rozenite/*`) over the SimDeck origin. The upstream request omits the -/// browser `Origin` so Metro's dev-middleware does not reject it. -pub async fn fetch_metro_frontend_asset( +/// Reverse-proxies a Metro HTTP path over the SimDeck origin. The upstream +/// request omits the browser `Origin` so Metro's dev-middleware does not reject +/// DevTools resources. +pub async fn fetch_metro_resource( port: u16, path: &str, query: Option<&str>, + method: &str, + body: Option<&[u8]>, + content_type: Option<&str>, ) -> Result { - if !is_metro_frontend_path(path) { - return Err("Not a Metro DevTools frontend asset path.".to_owned()); + if !is_metro_proxy_path(path) { + return Err("Not a safe Metro proxy path.".to_owned()); + } + if !is_metro_proxy_method(method) { + return Err("Not an allowed Metro proxy method.".to_owned()); } - let address = format!("{DEVTOOLS_HOST}:{port}"); let target = match query { Some(query) => format!("{path}?{query}"), None => path.to_owned(), }; - let mut stream = timeout(METRO_ASSET_TIMEOUT, TcpStream::connect(&address)) - .await - .map_err(|_| format!("Timed out connecting to Metro at {address}."))? - .map_err(|error| format!("Unable to connect to Metro at {address}: {error}"))?; - let request = format!( - "GET {target} HTTP/1.1\r\nHost: {address}\r\nAccept: */*\r\nConnection: close\r\n\r\n" + let body = body.unwrap_or(&[]); + let (address, mut stream) = + connect_loopback_devtools_service(port, METRO_ASSET_TIMEOUT, "Metro").await?; + let mut request = format!( + "{method} {target} HTTP/1.1\r\nHost: {address}\r\nAccept: */*\r\nConnection: close\r\n" ); + if !body.is_empty() { + request.push_str(&format!("Content-Length: {}\r\n", body.len())); + if let Some(content_type) = content_type { + request.push_str(&format!("Content-Type: {content_type}\r\n")); + } + } + request.push_str("\r\n"); timeout(METRO_ASSET_TIMEOUT, stream.write_all(request.as_bytes())) .await .map_err(|_| "Timed out requesting Metro asset.".to_owned())? .map_err(|error| format!("Unable to request Metro asset: {error}"))?; + if !body.is_empty() { + timeout(METRO_ASSET_TIMEOUT, stream.write_all(body)) + .await + .map_err(|_| "Timed out writing Metro request body.".to_owned())? + .map_err(|error| format!("Unable to write Metro request body: {error}"))?; + } let mut response = Vec::new(); let mut chunk = [0_u8; 16384]; @@ -884,10 +981,7 @@ pub async fn fetch_metro_frontend_asset( .and_then(|status| status.parse::().ok()) .unwrap_or(0); let content_type = header_value(&headers, "content-type"); - let body = content_length(&headers) - .and_then(|length| body.get(..length)) - .unwrap_or(body) - .to_vec(); + let body = decoded_http_body(&headers, body)?; Ok(ProxiedAsset { status, content_type, @@ -901,6 +995,114 @@ pub struct ProxiedAsset { pub body: Vec, } +pub fn rewrite_metro_proxy_asset( + port: u16, + asset_path: &str, + content_type: Option<&str>, + body: Vec, +) -> Vec { + if !is_rewritable_metro_proxy_asset(asset_path, content_type) { + return body; + } + + match String::from_utf8(body) { + Ok(text) => rewrite_metro_proxy_text(port, &text).into_bytes(), + Err(error) => error.into_bytes(), + } +} + +fn is_rewritable_metro_proxy_asset(asset_path: &str, content_type: Option<&str>) -> bool { + let path = asset_path + .split_once('?') + .map(|(path, _)| path) + .unwrap_or(asset_path) + .to_ascii_lowercase(); + if matches!( + Path::new(&path) + .extension() + .and_then(|extension| extension.to_str()), + Some("css" | "html" | "js" | "json" | "map" | "mjs") + ) { + return true; + } + + let Some(content_type) = content_type else { + return false; + }; + let media_type = content_type + .split(';') + .next() + .unwrap_or("") + .trim() + .to_ascii_lowercase(); + media_type.starts_with("text/") + || matches!( + media_type.as_str(), + "application/ecmascript" + | "application/javascript" + | "application/json" + | "application/manifest+json" + | "application/x-javascript" + ) +} + +fn rewrite_metro_proxy_text(port: u16, text: &str) -> String { + let proxy_prefix = format!("/api/metro/{port}"); + let text = rewrite_loopback_metro_origins(port, text, &proxy_prefix); + rewrite_absolute_metro_paths(&text, &proxy_prefix) +} + +fn rewrite_loopback_metro_origins(port: u16, text: &str, proxy_prefix: &str) -> String { + [ + format!("http://127.0.0.1:{port}"), + format!("http://localhost:{port}"), + format!("http://[::1]:{port}"), + ] + .into_iter() + .fold(text.to_owned(), |rewritten, origin| { + rewritten.replace(&origin, proxy_prefix) + }) +} + +fn rewrite_absolute_metro_paths(text: &str, proxy_prefix: &str) -> String { + let mut rewritten = String::with_capacity(text.len()); + let mut index = 0; + while index < text.len() { + let remaining = &text[index..]; + let metro_path = ["/rozenite/", "/debugger-frontend/"] + .into_iter() + .find(|prefix| remaining.starts_with(prefix)); + if let Some(metro_path) = metro_path { + let previous = text[..index].chars().next_back(); + if previous + .map(is_absolute_metro_path_boundary) + .unwrap_or(true) + { + rewritten.push_str(proxy_prefix); + rewritten.push_str(metro_path); + index += metro_path.len(); + continue; + } + } + + let character = remaining + .chars() + .next() + .expect("remaining text is non-empty"); + rewritten.push(character); + index += character.len_utf8(); + } + rewritten +} + +fn is_absolute_metro_path_boundary(character: char) -> bool { + character.is_ascii_whitespace() + || matches!( + character, + '"' | '\'' | '`' | '(' | '[' | '{' | '=' | ':' | ',' | ';' + ) +} + fn header_value(headers: &str, name: &str) -> Option { headers.lines().find_map(|line| { let (key, value) = line.split_once(':')?; @@ -910,6 +1112,44 @@ fn header_value(headers: &str, name: &str) -> Option { }) } +fn decoded_http_body(headers: &str, body: &[u8]) -> Result, String> { + if header_value(headers, "transfer-encoding").is_some_and(|value| { + value + .split(',') + .any(|encoding| encoding.trim().eq_ignore_ascii_case("chunked")) + }) { + return decode_chunked_body(body); + } + Ok(content_length(headers) + .and_then(|length| body.get(..length)) + .unwrap_or(body) + .to_vec()) +} + +fn decode_chunked_body(mut body: &[u8]) -> Result, String> { + let mut decoded = Vec::new(); + loop { + let line_end = body + .windows(2) + .position(|window| window == b"\r\n") + .ok_or_else(|| "Metro returned malformed chunked HTTP.".to_owned())?; + let size_line = std::str::from_utf8(&body[..line_end]) + .map_err(|_| "Metro returned non-UTF-8 chunk metadata.".to_owned())?; + let size_text = size_line.split(';').next().unwrap_or("").trim(); + let size = usize::from_str_radix(size_text, 16) + .map_err(|_| "Metro returned invalid chunk size.".to_owned())?; + body = &body[line_end + 2..]; + if size == 0 { + return Ok(decoded); + } + if body.len() < size + 2 || body.get(size..size + 2) != Some(b"\r\n") { + return Err("Metro returned truncated chunked HTTP.".to_owned()); + } + decoded.extend_from_slice(&body[..size]); + body = &body[size + 2..]; + } +} + fn split_path_query(value: &str) -> (&str, Option<&str>) { match value.split_once('?') { Some((path, query)) => (path, Some(query)), @@ -955,6 +1195,39 @@ fn percent_encode_query_component(value: &str) -> String { encoded } +fn percent_decode_query_component(value: &str) -> String { + let mut decoded = Vec::with_capacity(value.len()); + let bytes = value.as_bytes(); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + let high = hex_value(bytes[index + 1]); + let low = hex_value(bytes[index + 2]); + if let (Some(high), Some(low)) = (high, low) { + decoded.push((high << 4) | low); + index += 3; + continue; + } + } + if bytes[index] == b'+' { + decoded.push(b' '); + } else { + decoded.push(bytes[index]); + } + index += 1; + } + String::from_utf8_lossy(&decoded).into_owned() +} + +fn hex_value(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + fn unique_strings(values: Vec) -> Vec { values.into_iter().fold(Vec::new(), |mut unique, value| { if !unique.contains(&value) { @@ -1772,6 +2045,7 @@ fn timestamp_ms() -> f64 { #[cfg(test)] mod tests { use super::*; + use tokio::net::TcpListener; #[test] fn upstream_websocket_origin_matches_metro_dev_server() { @@ -1801,7 +2075,26 @@ mod tests { assert_eq!( url, - "/api/metro-frontend/8081/rozenite/rn_fusebox.html?ws=127.0.0.1%3A4310%2Fapi%2Fsimulators%2FABC%2Fdevtools%2Ftargets%2Fmetro-8081-target%2Fsocket&device=ios" + "/api/metro/8081/rozenite/rn_fusebox.html?ws=127.0.0.1%3A4310%2Fapi%2Fsimulators%2FABC%2Fdevtools%2Ftargets%2Fmetro-8081-target%2Fsocket&device=ios" + ); + } + + #[test] + fn metro_devtools_frontend_url_preserves_rozenite_inspector_metadata_from_encoded_socket() { + let entry = json!({ + "devtoolsFrontendUrl": "/rozenite/rn_fusebox.html?ws=%2Finspector%2Fdebug%3Fdevice%3D8067dc%26page%3D1&sources.hide_add_folder=true", + "webSocketDebuggerUrl": "ws://127.0.0.1:8081/inspector/debug?device=8067dc&page=1" + }); + + let url = metro_devtools_frontend_url( + 8081, + &entry, + "ws://127.0.0.1:4310/api/simulators/ABC/devtools/targets/metro-8081-target/socket", + ); + + assert_eq!( + url, + "/api/metro/8081/rozenite/rn_fusebox.html?ws=127.0.0.1%3A4310%2Fapi%2Fsimulators%2FABC%2Fdevtools%2Ftargets%2Fmetro-8081-target%2Fsocket&sources.hide_add_folder=true&device=8067dc&page=1" ); } @@ -1820,7 +2113,7 @@ mod tests { assert_eq!( url, - "/api/metro-frontend/8081/rozenite/rn_fusebox.html?ws=simdeck.local%3A4310%2Fapi%2Fsimulators%2FABC%2Fdevtools%2Ftargets%2Fmetro-8081-target%2Fsocket&panel=redux" + "/api/metro/8081/rozenite/rn_fusebox.html?ws=simdeck.local%3A4310%2Fapi%2Fsimulators%2FABC%2Fdevtools%2Ftargets%2Fmetro-8081-target%2Fsocket&panel=redux" ); } @@ -1839,7 +2132,7 @@ mod tests { assert_eq!( url, - "/api/metro-frontend/8081/debugger-frontend/rn_fusebox.html?ws=127.0.0.1%3A4310%2Fapi%2Fsimulators%2FABC%2Fdevtools%2Ftargets%2Fmetro-8081-target%2Fsocket&device=ios" + "/api/metro/8081/debugger-frontend/rn_fusebox.html?ws=127.0.0.1%3A4310%2Fapi%2Fsimulators%2FABC%2Fdevtools%2Ftargets%2Fmetro-8081-target%2Fsocket&device=ios" ); } @@ -1857,7 +2150,7 @@ mod tests { assert_eq!( url, - "/api/metro-frontend/8081/debugger-frontend/rn_fusebox.html?ws=127.0.0.1%3A4310%2Fapi%2Fsimulators%2FABC%2Fdevtools%2Ftargets%2Fmetro-8081-target%2Fsocket" + "/api/metro/8081/debugger-frontend/rn_fusebox.html?ws=127.0.0.1%3A4310%2Fapi%2Fsimulators%2FABC%2Fdevtools%2Ftargets%2Fmetro-8081-target%2Fsocket" ); } @@ -1898,9 +2191,191 @@ mod tests { )); assert!(target .devtools_frontend_url - .starts_with("/api/metro-frontend/8081/debugger-frontend/rn_fusebox.html?")); + .starts_with("/api/metro/8081/debugger-frontend/rn_fusebox.html?")); assert!(target.devtools_frontend_url.contains( "ws=127.0.0.1%3A4310%2Fapi%2Fsimulators%2FABC%2Fdevtools%2Ftargets%2Fmetro-8081-target-1%2Fsocket%3FsimdeckToken%3Dsecret%2520token" )); } + + #[test] + fn metro_proxy_path_allows_full_metro_server_surface() { + assert!(is_metro_proxy_path("/debugger-frontend/rn_fusebox.html")); + assert!(is_metro_proxy_path("/rozenite/panel.js")); + assert!(is_metro_proxy_path("/json/list")); + assert!(is_metro_proxy_path("/symbolicate")); + assert!(is_metro_proxy_path("/index.bundle")); + assert!(!is_metro_proxy_path("")); + assert!(!is_metro_proxy_path("http://localhost:8081/json/list")); + assert!(!is_metro_proxy_path("/../Info.plist")); + assert!(is_metro_proxy_method("GET")); + assert!(is_metro_proxy_method("HEAD")); + assert!(is_metro_proxy_method("POST")); + assert!(!is_metro_proxy_method("DELETE")); + } + + #[test] + fn metro_proxy_rewrites_absolute_rozenite_assets_to_port_scoped_proxy() { + let body = br#" + import * as UI from "/rozenite/ui/legacy/legacy.js"; + import "/debugger-frontend/models/react_native/react_native.js"; + const url = new URL(location.href); + url.pathname = "/rozenite/plugins/@rozenite_controls-plugin"; + const nested = "https://example.com/rozenite/docs"; + const alreadyProxied = "/api/metro/8091/rozenite/host.js"; + "#; + + let rewritten = rewrite_metro_proxy_asset( + 8091, + "/rozenite/host.js", + Some("application/javascript"), + body.to_vec(), + ); + let rewritten = String::from_utf8(rewritten).unwrap(); + + assert!(rewritten.contains("from \"/api/metro/8091/rozenite/ui/legacy/legacy.js\"")); + assert!(rewritten.contains( + "import \"/api/metro/8091/debugger-frontend/models/react_native/react_native.js\"" + )); + assert!(rewritten.contains( + "url.pathname = \"/api/metro/8091/rozenite/plugins/@rozenite_controls-plugin\"" + )); + assert!(rewritten.contains("https://example.com/rozenite/docs")); + assert!(rewritten.contains("\"/api/metro/8091/rozenite/host.js\"")); + } + + #[test] + fn metro_proxy_rewrites_loopback_metro_origins() { + let rewritten = rewrite_metro_proxy_asset( + 8091, + "/rozenite/plugin.json", + Some("application/json"), + br#"{"script":"http://localhost:8091/rozenite/plugin.js","ipv4":"http://127.0.0.1:8091/debugger-frontend/main.js"}"#.to_vec(), + ); + let rewritten = String::from_utf8(rewritten).unwrap(); + + assert!(rewritten.contains("\"/api/metro/8091/rozenite/plugin.js\"")); + assert!(rewritten.contains("\"/api/metro/8091/debugger-frontend/main.js\"")); + } + + #[tokio::test] + async fn fetch_metro_asset_proxies_full_path_without_origin_header() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = vec![0_u8; 1024]; + let count = socket.read(&mut request).await.unwrap(); + let request = String::from_utf8_lossy(&request[..count]); + assert!(request.starts_with("GET /json/list?platform=ios HTTP/1.1\r\n")); + assert!(!request.to_ascii_lowercase().contains("\r\norigin:")); + socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n[]", + ) + .await + .unwrap(); + }); + + let asset = + fetch_metro_resource(port, "/json/list", Some("platform=ios"), "GET", None, None) + .await + .unwrap(); + + server.await.unwrap(); + assert_eq!(asset.status, 200); + assert_eq!(asset.content_type.as_deref(), Some("application/json")); + assert_eq!(asset.body, b"[]"); + } + + #[tokio::test] + async fn fetch_metro_resource_falls_back_to_ipv6_loopback() { + let listener = TcpListener::bind("[::1]:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = vec![0_u8; 1024]; + let count = socket.read(&mut request).await.unwrap(); + let request = String::from_utf8_lossy(&request[..count]); + assert!(request.starts_with("GET /rozenite/rn_fusebox.html HTTP/1.1\r\n")); + assert!(request.contains(&format!("\r\nHost: [::1]:{port}\r\n"))); + socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 11\r\n\r\nroze-ready!", + ) + .await + .unwrap(); + }); + + let asset = + fetch_metro_resource(port, "/rozenite/rn_fusebox.html", None, "GET", None, None) + .await + .unwrap(); + + server.await.unwrap(); + assert_eq!(asset.status, 200); + assert_eq!(asset.content_type.as_deref(), Some("text/html")); + assert_eq!(asset.body, b"roze-ready!"); + } + + #[tokio::test] + async fn fetch_metro_resource_proxies_post_body_for_symbolication() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let mut chunk = [0_u8; 1024]; + loop { + let count = socket.read(&mut chunk).await.unwrap(); + if count == 0 { + break; + } + request.extend_from_slice(&chunk[..count]); + if request + .windows(12) + .any(|window| window == b"{\"stack\":[]}") + { + break; + } + } + let request = String::from_utf8_lossy(&request); + assert!(request.starts_with("POST /symbolicate HTTP/1.1\r\n")); + assert!(request.contains("\r\nContent-Length: 12\r\n")); + assert!(request.contains("\r\nContent-Type: application/json\r\n")); + assert!(request.ends_with("{\"stack\":[]}")); + socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n2\r\n{}\r\n0\r\n\r\n", + ) + .await + .unwrap(); + }); + + let asset = fetch_metro_resource( + port, + "/symbolicate", + None, + "POST", + Some(br#"{"stack":[]}"#), + Some("application/json"), + ) + .await + .unwrap(); + + server.await.unwrap(); + assert_eq!(asset.status, 200); + assert_eq!(asset.content_type.as_deref(), Some("application/json")); + assert_eq!(asset.body, b"{}"); + } + + #[test] + fn decoded_http_body_decodes_chunked_metro_responses() { + let body = decoded_http_body( + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n", + b"5\r\nhello\r\n6;ext=1\r\n world\r\n0\r\n\r\n", + ) + .unwrap(); + + assert_eq!(body, b"hello world"); + } }