diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index d226f43f..c753eda0 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mobile-dev", - "version": "0.1.161", + "version": "0.1.162", "description": "Keep a simulator beside the chat while building, running, or debugging iOS and Android apps. Stream devices, read native and Metro logs, analyze CPU and React profiles with TraceSift, render interactive CPU, memory, and FPS recordings, and control apps with Mobile Dev tools.", "author": { "name": "Callstack" diff --git a/docs/mcp-tools.md b/docs/mcp-tools.md index 2114b2a2..740d6c45 100644 --- a/docs/mcp-tools.md +++ b/docs/mcp-tools.md @@ -26,7 +26,7 @@ | `mobile_read_logs` | Read a log batch and source status | | `mobile_logs_keep_alive` | Keep background collection alive without sending logs (app only) | | `mobile_logs_close` | Stop a session's log readers | -| `mobile_crashes_session` | Watch one device's crash reports for the panel's crash toast; read and close it like a log session (app only) | +| `mobile_crashes_session` | Watch one simulator, connected iPhone, or Android device for crashes for the panel's crash toast; read and close it like a log session (app only) | | `mobile_performance_sources` | Read running apps and foreground identity on the selected iOS or Android device | | `mobile_cpu_session` | Connect a native process and thread CPU plus memory monitor | | `mobile_read_cpu` | Read live CPU and memory samples and connection status | diff --git a/docs/telemetry.md b/docs/telemetry.md index 978653da..be93728f 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -277,6 +277,8 @@ Since 0.1.99, `ui.logs.query_parse` measures query compilation in milliseconds o Since 0.1.161, each visible simulator panel opens an app-only crash session for its booted iOS simulator or Android device, independent of Logs; log sessions run the same crash watcher scoped to their app or process. `logs.crash.detected` counts crash records, with `device_platform` and `crash_source`: `ios_exit` for SpringBoard's immediate crash-signal exit line, `ios_log` for the app's own crash message read from the unified log about a second later (uncaught exceptions, Swift fatal errors, React Native fatal JS errors; none for an external signal), `ios_report` for the `.ips` report written 30-45 seconds later, and `android_logcat` for `FATAL EXCEPTION` blocks and libc fatal signals. One iOS crash therefore usually records several sources. `logs.crash.parse` measures `.ips` parsing in milliseconds, the only expensive parse. Both use the `logs` surface and are recorded per crash because crashes are rare. In the UI, `ui.crash.toast_shown`, `ui.crash.attached`, `ui.crash.sent_to_chat`, `ui.crash.dismissed`, `ui.crash.report_attached` (a stack arriving for a toast already shown) and `ui.crash.watch_retry` count toast activity and session recovery; chat delivery time stays under `ui.logs.send`. Crash sessions long-poll for up to 20 seconds and pause reads while the document is hidden, sending only keep-alives. Crash messages, stack traces, app IDs, process names, report paths and device IDs stay local. +Since 0.1.162, the crash session also covers a connected physical iPhone. A second relay stream from the bundled log reader, filtered to SpringBoard, gives the immediate `ios_exit`; the reader's `--crash-reports` mode then copies the phone's new `.ips` reports through `com.apple.crashreportmover` and `com.apple.crashreportcopymobile` every 3 seconds for up to 2 minutes, recording `ios_report` when one matches the exit's PID and bundle ID. Physical iPhones record no `ios_log`. iOS crash metrics now carry `device_kind` (`simulator` or `physical`); earlier iOS crash metrics were all simulators. `logs.crash.report_wait` measures, in milliseconds, how long a physical iPhone's report took to become readable after its exit was seen, so slow reports can be told apart from slow parsing. The report mode records no native timing, which keeps the reader's `connect` timing limited to log streaming becoming ready. Report contents stay local. + `ui.logs.session_expired` counts expired sessions that trigger automatic recovery. It uses the active Logs context without sending session IDs, error text or log content. `ui.logs.retention` measures bounded buffer updates in milliseconds. `ui.logs.evicted` counts rows removed locally to meet the shared row and text limits; `ui.logs.dropped` still counts only rows lost from the server buffer. Existing filter timing and row gauges cover the resulting list. These measurements contain no log text or source metadata. diff --git a/native/ios-logs/README.md b/native/ios-logs/README.md index 0ecceb98..0c355bc0 100644 --- a/native/ios-logs/README.md +++ b/native/ios-logs/README.md @@ -13,6 +13,13 @@ are capped at 16 KiB, sender paths at 4 KiB, and subsystem/category values at 1 UTC date and microseconds. An optional exact executable-name filter operates before JSON serialization and survives PID changes across app restarts. +`--crash-reports ` replaces the executable name for a one-shot +report copy. It asks `com.apple.crashreportmover` to move pending reports, then +lists the `com.apple.crashreportcopymobile` directory over AFC and prints one +`{"name","modified","text"}` line per top-level `.ips` report modified at or after +that time in the phone's clock. Reports over 8 MiB are skipped, and nothing is +removed from the phone. + The reader needs an existing trusted USB or Wi-Fi connection. It does not launch an app, attach a debugger, install an SDK, or change signing requirements. It cannot capture ordinary stdout/stderr print statements or recover values that diff --git a/native/ios-logs/collector.c b/native/ios-logs/collector.c index d205da86..72064f7b 100644 --- a/native/ios-logs/collector.c +++ b/native/ios-logs/collector.c @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -18,6 +19,7 @@ #define MAX_PACKET (1024 * 1024) #define MAX_MESSAGE 16384 +#define MAX_REPORT (8 * 1024 * 1024) static volatile sig_atomic_t stopping = 0; @@ -214,36 +216,115 @@ static int emit_record(const char *bytes, uint32_t length, const char *process_f return failed ? -1 : 0; } -int main(int argc, char **argv) { - mobile_dev_telemetry_init("ios-logs"); - double connected_at = mobile_dev_telemetry_now(); - if (argc < 4 || argc > 5 || strcmp(argv[1], "--device") != 0 - || (strcmp(argv[3], "usb") != 0 && strcmp(argv[3], "network") != 0)) { - fprintf(stderr, "Usage: mobile-dev-ios-logs --device [executable-name]\n"); - return 2; +// The mover replies "ping" once pending crash reports are in the directory the copy service reads. +static int move_crash_reports(idevice_t device, lockdownd_client_t lockdown) { + lockdownd_service_descriptor_t descriptor = NULL; + lockdownd_error_t started = lockdownd_start_service(lockdown, "com.apple.crashreportmover", &descriptor); + if (started != LOCKDOWN_E_SUCCESS) { + fprintf(stderr, "Cannot start the crash report mover: %s. Unlock the iPhone and retry.\n", lockdownd_strerror(started)); + return -1; } - signal(SIGTERM, stop); - signal(SIGINT, stop); - signal(SIGPIPE, stop); - setvbuf(stdout, NULL, _IOLBF, 0); - idevice_t device = NULL; - lockdownd_client_t lockdown = NULL; + service_client_t mover = NULL; + service_error_t connected = service_client_new(device, descriptor, &mover); + lockdownd_service_descriptor_free(descriptor); + if (connected != SERVICE_E_SUCCESS) { + fprintf(stderr, "Cannot open the crash report mover (%d).\n", connected); + return -1; + } + char ping[4] = { 0 }; + uint32_t received = 0; + for (int attempt = 0; attempt < 10 && received < sizeof(ping) && stopping == 0; attempt++) { + uint32_t bytes = 0; + service_error_t result = service_receive_with_timeout(mover, ping + received, sizeof(ping) - received, &bytes, 2000); + received += bytes; + if (result != SERVICE_E_SUCCESS && result != SERVICE_E_TIMEOUT) break; + } + service_client_free(mover); + if (received == sizeof(ping) && memcmp(ping, "ping", sizeof(ping)) == 0) return 0; + fprintf(stderr, "The iPhone did not finish moving its crash reports.\n"); + return -1; +} + +static char *read_report(afc_client_t afc, const char *path, uint64_t size) { + uint64_t handle = 0; + if (afc_file_open(afc, path, AFC_FOPEN_RDONLY, &handle) != AFC_E_SUCCESS) return NULL; + char *text = malloc(size + 1); + uint64_t offset = 0; + while (text != NULL && offset < size) { + uint32_t bytes = 0; + afc_error_t result = afc_file_read(afc, handle, text + offset, (uint32_t)(size - offset), &bytes); + if (result != AFC_E_SUCCESS || bytes == 0) break; + offset += bytes; + } + afc_file_close(afc, handle); + if (text == NULL || offset != size) { + free(text); + return NULL; + } + text[size] = '\0'; + return text; +} + +// Print each .ips report modified at or after since_ms as {"name","modified","text"}. +static int copy_crash_reports(idevice_t device, lockdownd_client_t lockdown, uint64_t since_ms) { lockdownd_service_descriptor_t descriptor = NULL; - service_client_t client = NULL; - int exit_code = 1; - enum idevice_options lookup = strcmp(argv[3], "network") == 0 ? IDEVICE_LOOKUP_NETWORK : IDEVICE_LOOKUP_USBMUX; - idevice_error_t found = idevice_new_with_options(&device, argv[2], lookup); - if (found != IDEVICE_E_SUCCESS) { - fprintf(stderr, "The selected iPhone is unavailable to libimobiledevice over %s (%d).\n", argv[3], found); - goto cleanup; + lockdownd_error_t started = lockdownd_start_service(lockdown, "com.apple.crashreportcopymobile", &descriptor); + if (started != LOCKDOWN_E_SUCCESS) { + fprintf(stderr, "Cannot open crash reports: %s. Unlock the iPhone and retry.\n", lockdownd_strerror(started)); + return -1; } - lockdownd_error_t paired = lockdownd_client_new_with_handshake(device, &lockdown, "mobile-dev-ios-logs"); - if (paired != LOCKDOWN_E_SUCCESS) { - const char *description = lockdownd_strerror(paired); - fprintf(stderr, "Cannot connect to the iPhone: %s. Unlock it and trust this Mac.\n", description); - goto cleanup; + afc_client_t afc = NULL; + afc_error_t connected = afc_client_new(device, descriptor, &afc); + lockdownd_service_descriptor_free(descriptor); + if (connected != AFC_E_SUCCESS) { + fprintf(stderr, "Cannot read crash reports (%d).\n", connected); + return -1; + } + char **names = NULL; + afc_error_t listed = afc_read_directory(afc, "/", &names); + int result = listed == AFC_E_SUCCESS ? 0 : -1; + if (result != 0) fprintf(stderr, "Cannot list crash reports (%d).\n", listed); + for (size_t index = 0; result == 0 && names != NULL && names[index] != NULL && stopping == 0; index++) { + const char *name = names[index]; + size_t length = strlen(name); + if (length < 5 || strcmp(name + length - 4, ".ips") != 0) continue; + char path[1024]; + if (snprintf(path, sizeof(path), "/%s", name) >= (int)sizeof(path)) continue; + plist_t info = NULL; + if (afc_get_file_info_plist(afc, path, &info) != AFC_E_SUCCESS || info == NULL) continue; + const char *format = plist_get_string_ptr(plist_dict_get_item(info, "st_ifmt"), NULL); + int regular = format != NULL && strcmp(format, "S_IFREG") == 0; + uint64_t size = plist_dict_get_uint(info, "st_size"); + uint64_t modified_ms = plist_dict_get_uint(info, "st_mtime") / 1000000; + plist_free(info); + if (regular == 0 || modified_ms < since_ms) continue; + if (size == 0 || size > MAX_REPORT) { + fprintf(stderr, "Skipping crash report %s: %llu bytes.\n", name, (unsigned long long)size); + continue; + } + char *text = read_report(afc, path, size); + if (text == NULL) { + fprintf(stderr, "Cannot read crash report %s.\n", name); + continue; + } + fputs("{\"name\":", stdout); + json_string(name, length, 1024); + fprintf(stdout, ",\"modified\":%llu", (unsigned long long)modified_ms); + json_field("text", text, MAX_REPORT); + fputs("}\n", stdout); + free(text); + if (ferror(stdout)) result = -1; } - lockdownd_error_t started = lockdownd_start_service(lockdown, OSTRACE_SERVICE_NAME, &descriptor); + if (names != NULL) afc_dictionary_free(names); + afc_client_free(afc); + return result; +} + +static int stream_logs(idevice_t device, lockdownd_client_t *lockdown, const char *filter, double connected_at) { + lockdownd_service_descriptor_t descriptor = NULL; + service_client_t client = NULL; + int result = -1; + lockdownd_error_t started = lockdownd_start_service(*lockdown, OSTRACE_SERVICE_NAME, &descriptor); if (started != LOCKDOWN_E_SUCCESS) { const char *description = lockdownd_strerror(started); fprintf(stderr, "Cannot start unified logs: %s. Unlock the iPhone and retry.\n", description); @@ -256,15 +337,14 @@ int main(int argc, char **argv) { } lockdownd_service_descriptor_free(descriptor); descriptor = NULL; - lockdownd_client_free(lockdown); - lockdown = NULL; - int result = start_activity(client); + lockdownd_client_free(*lockdown); + *lockdown = NULL; + result = start_activity(client); if (result != 0) goto cleanup; puts("{\"ready\":true}"); double ready_at = mobile_dev_telemetry_now(); double connect_ms = ready_at - connected_at; mobile_dev_telemetry_timing(MOBILE_DEV_CONNECT, connect_ms); - const char *filter = argc == 5 ? argv[4] : NULL; while (stopping == 0) { char *bytes = NULL; uint32_t length = 0; @@ -285,6 +365,48 @@ int main(int argc, char **argv) { cleanup: if (client != NULL) service_client_free(client); if (descriptor != NULL) lockdownd_service_descriptor_free(descriptor); + return result; +} + +int main(int argc, char **argv) { + mobile_dev_telemetry_init("ios-logs"); + double connected_at = mobile_dev_telemetry_now(); + int reports = argc == 6 && strcmp(argv[4], "--crash-reports") == 0; + char *since_end = NULL; + unsigned long long since_ms = reports ? strtoull(argv[5], &since_end, 10) : 0; + if (argc < 4 || argc > 6 || strcmp(argv[1], "--device") != 0 + || (strcmp(argv[3], "usb") != 0 && strcmp(argv[3], "network") != 0) + || (argc == 6 && (reports == 0 || since_end == argv[5] || *since_end != '\0'))) { + fprintf(stderr, "Usage: mobile-dev-ios-logs --device [executable-name | --crash-reports ]\n"); + return 2; + } + signal(SIGTERM, stop); + signal(SIGINT, stop); + signal(SIGPIPE, stop); + setvbuf(stdout, NULL, _IOLBF, 0); + idevice_t device = NULL; + lockdownd_client_t lockdown = NULL; + int exit_code = 1; + enum idevice_options lookup = strcmp(argv[3], "network") == 0 ? IDEVICE_LOOKUP_NETWORK : IDEVICE_LOOKUP_USBMUX; + idevice_error_t found = idevice_new_with_options(&device, argv[2], lookup); + if (found != IDEVICE_E_SUCCESS) { + fprintf(stderr, "The selected iPhone is unavailable to libimobiledevice over %s (%d).\n", argv[3], found); + goto cleanup; + } + lockdownd_error_t paired = lockdownd_client_new_with_handshake(device, &lockdown, "mobile-dev-ios-logs"); + if (paired != LOCKDOWN_E_SUCCESS) { + const char *description = lockdownd_strerror(paired); + fprintf(stderr, "Cannot connect to the iPhone: %s. Unlock it and trust this Mac.\n", description); + goto cleanup; + } + if (reports) { + int moved = move_crash_reports(device, lockdown); + if (moved == 0 && copy_crash_reports(device, lockdown, since_ms) == 0) exit_code = 0; + } else { + const char *filter = argc == 5 ? argv[4] : NULL; + stream_logs(device, &lockdown, filter, connected_at); + } +cleanup: if (lockdown != NULL) lockdownd_client_free(lockdown); if (device != NULL) idevice_free(device); if (stopping) exit_code = 0; diff --git a/package-lock.json b/package-lock.json index 56f680dd..2d1d3de7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-mobile-dev-plugin", - "version": "0.1.161", + "version": "0.1.162", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-mobile-dev-plugin", - "version": "0.1.161", + "version": "0.1.162", "dependencies": { "@base-ui/react": "^1.8.0", "@fontsource-variable/inter": "^5.3.0", diff --git a/package.json b/package.json index 5115495e..f08317a9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-mobile-dev-plugin", - "version": "0.1.161", + "version": "0.1.162", "private": true, "type": "module", "engines": { diff --git a/src/server/crash-reports.ts b/src/server/crash-reports.ts index e1a69111..3761caed 100644 --- a/src/server/crash-reports.ts +++ b/src/server/crash-reports.ts @@ -4,11 +4,14 @@ import type { FSWatcher } from "node:fs"; import { readFile, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; import { promisify } from "node:util"; import type { CrashWatchTarget, LogRecord, NativeLogTarget } from "../shared/logs.ts"; import { parseLogcat } from "./log-parsers.ts"; +import { listIosAppExecutables } from "./ios-devices.ts"; import { adbPath, keepReading, runLogProcess } from "./native-logs.ts"; import type { StopLogSource } from "./native-logs.ts"; +import { physicalIosCrashReports, physicalIosLogCommand } from "./physical-ios-logs.ts"; import { recordCrash } from "./telemetry.ts"; const execute = promisify(execFile); @@ -23,6 +26,7 @@ export function startCrashWatch(target: CrashWatchTarget, emit: Emit): StopLogSo const scoped: Emit = crash => emit({ ...crash, deviceId: target.deviceId }); let running: Promise | undefined; if (target.platform === "android") running = watchAndroidCrashes(target.deviceId, scoped, signal); + else if (target.kind === "physical") running = watchPhysicalIosCrashes(target.deviceId, scoped, signal); else if (process.platform === "darwin") running = watchIosSimulatorCrashes(target.deviceId, scoped, signal); return async () => { controller.abort(); await running; }; } @@ -32,9 +36,8 @@ export function crashWatchFor(native: NativeLogTarget): { target: CrashWatchTarg if (native.platform === "android") { return { target: { platform: "android", deviceId: native.deviceId }, includes: crash => native.packageName === undefined || crash.appId === native.packageName }; } - // Physical iPhones keep their crash reports until the device syncs. - if (native.kind === "physical") return; - return { target: { platform: "ios", deviceId: native.deviceId }, includes: crash => (native.pid === undefined || crash.pid === native.pid) + const target: CrashWatchTarget = native.kind === "physical" ? { platform: "ios", kind: "physical", deviceId: native.deviceId } : { platform: "ios", deviceId: native.deviceId }; + return { target, includes: crash => (native.pid === undefined || crash.pid === native.pid) && (native.process === undefined || crash.process === native.process) }; } @@ -57,8 +60,7 @@ function watchIosSimulatorCrashes(deviceId: string, emit: Emit, signal: AbortSig const exited = async (exit: IosAppExit) => { const process = await executable(exit.appId); if (signal.aborted) return; - const crash: LogRecord = { timestamp: exit.timestamp, level: "error", source: "native", origin: "ios", kind: "crash", appId: exit.appId, pid: exit.pid, - process, reason: exit.signal, message: `${process ?? exit.appId} crashed: ${exit.signal}` }; + const crash = exitCrash(exit, process); recordCrash("ios_exit"); emit(crash); const { stdout } = await execute("xcrun", ["simctl", "spawn", deviceId, "log", "show", "--style", "ndjson", "--last", "20s", "--predicate", iosCrashLogPredicate(exit.pid)], @@ -77,6 +79,12 @@ export const iosAppExitPredicate = 'process == "SpringBoard" AND category == "Wo const crashSignals = new Set(["SIGABRT", "SIGSEGV", "SIGBUS", "SIGILL", "SIGTRAP", "SIGFPE", "SIGSYS", "SIGQUIT"]); export type IosAppExit = { appId: string; pid: number; signal: string; timestamp: string }; +/** The immediate crash record for an exit, before any reason or stack is known. */ +function exitCrash(exit: IosAppExit, process?: string): LogRecord { + return { timestamp: exit.timestamp, level: "error", source: "native", origin: "ios", kind: "crash", appId: exit.appId, pid: exit.pid, + process, reason: exit.signal, message: `${process ?? exit.appId} crashed: ${exit.signal}` }; +} + /** Parse a SpringBoard app exit from `log stream --style ndjson`, keeping only crash signals. */ export function parseIosAppExit(line: string): IosAppExit | undefined { let data: { eventMessage?: unknown; timestamp?: unknown }; @@ -147,12 +155,12 @@ function applicationReason(asi: unknown): string | undefined { } } -/** Parse an .ips crash report, keeping only crashes from the given simulator. */ -export function parseIosCrashReport(text: string, deviceId: string): LogRecord | undefined { +/** Parse an .ips crash report. With a simulator ID, reports from anywhere else are ignored. */ +export function parseIosCrashReport(text: string, simulatorId?: string): LogRecord | undefined { const parts = splitReport(text); if (!parts) return; const { header, report } = parts; - if (typeof report.procPath !== "string" || !report.procPath.includes(`/CoreSimulator/Devices/${deviceId}/`)) return; + if (typeof report.procPath !== "string" || (simulatorId !== undefined && !report.procPath.includes(`/CoreSimulator/Devices/${simulatorId}/`))) return; const process = typeof report.procName === "string" ? report.procName : report.procPath.split("/").at(-1); const bundleId = report.bundleInfo?.CFBundleIdentifier ?? header.bundleID; const exception = report.exception ?? {}; @@ -198,7 +206,7 @@ export function watchIosCrashReports(deviceId: string, emit: Emit, signal: Abort const crash = parseIosCrashReport(text, deviceId); seen.add(name); if (!crash || signal.aborted) return; - recordCrash("ios_report", performance.now() - started); + recordCrash("ios_report", { parse: performance.now() - started }); emit(crash); } catch { if (attempt < 5) schedule(name, attempt + 1); } }; @@ -225,13 +233,62 @@ export function watchIosCrashReports(deviceId: string, emit: Emit, signal: Abort start(); } -// Crash reports and unified logs write timestamps like "2026-10-13 16:27:23.00 +0200". +// Crash reports and unified logs write timestamps like "2026-10-13 16:27:23.00 +0200"; the phone's +// log relay writes ISO UTC times like "2026-10-13T14:27:23.000000Z". function reportTimestamp(value: unknown): string { - const match = typeof value === "string" ? /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})(\.\d+)? ?([+-]\d{2}):?(\d{2})$/.exec(value) : null; - const date = match ? new Date(`${match[1]}T${match[2]}${(match[3] ?? "").slice(0, 4)}${match[4]}:${match[5]}`) : new Date(NaN); + const match = typeof value === "string" ? /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})(\.\d+)? ?(?:([+-]\d{2}):?(\d{2})|Z)$/.exec(value) : null; + const zone = match?.[4] === undefined ? "Z" : `${match[4]}:${match[5]}`; + const date = match ? new Date(`${match[1]}T${match[2]}${(match[3] ?? "").slice(0, 4)}${zone}`) : new Date(NaN); return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString(); } +// ---- Physical iPhone --------------------------------------------------------------------------- +// SpringBoard logs the exit through the phone's unified-log relay at once. The .ips report stays on +// the phone, so it is copied through the crash report service once ReportCrash has written it. + +const reportPollMs = 3000; +const reportDeadlineMs = 120000; + +const physicalSources = { exits: physicalIosLogCommand, reports: physicalIosCrashReports, executables: listIosAppExecutables }; + +export function watchPhysicalIosCrashes(deviceId: string, emit: Emit, signal: AbortSignal, sources = physicalSources, pollMs = reportPollMs): Promise { + // devicectl takes seconds, so app names load once up front and reload after an unknown app crashes. + const loadExecutables = () => sources.executables(deviceId, signal).catch(() => new Map()); + let executables = loadExecutables(); + const exited = async (exit: IosAppExit) => { + const known = await Promise.race([executables, delay(300, undefined, { signal }).then(() => undefined)]); + if (known && !known.has(exit.appId)) executables = loadExecutables(); + recordCrash("ios_exit", { deviceKind: "physical" }); + emit(exitCrash(exit, known?.get(exit.appId))); + const report = await waitForPhysicalReport(deviceId, exit, signal, sources.reports, pollMs); + if (report && !signal.aborted) emit(report); + }; + const exits = { status() {}, log: (exit: IosAppExit) => void exited(exit).catch(() => {}) }; + return keepReading(async () => { + const reader = await sources.exits({ platform: "ios", kind: "physical", deviceId, process: "SpringBoard" }, signal); + await runLogProcess(reader.command, reader.args, parseIosAppExit, exits, signal, '{"ready":true}'); + }, signal); +} + +// Report modification times and the exit timestamp both come from the phone's clock, so host clock +// skew does not apply; the margin only absorbs timestamp rounding. +async function waitForPhysicalReport(deviceId: string, exit: IosAppExit, signal: AbortSignal, reports: typeof physicalIosCrashReports, pollMs: number) { + const since = Date.parse(exit.timestamp) - 5000; + const started = performance.now(); + while (!signal.aborted && performance.now() - started < reportDeadlineMs) { + await delay(pollMs, undefined, { signal }).catch(() => {}); + if (signal.aborted) return; + const copied = await reports(deviceId, since, signal).catch(() => []); + for (const { text } of copied) { + const parsing = performance.now(); + const crash = parseIosCrashReport(text); + if (crash?.pid !== exit.pid || crash.appId !== exit.appId) continue; + recordCrash("ios_report", { parse: performance.now() - parsing, deviceKind: "physical", reportWait: performance.now() - started }); + return crash; + } + } +} + // ---- Android ---------------------------------------------------------------------------------- async function watchAndroidCrashes(deviceId: string, emit: Emit, signal: AbortSignal) { diff --git a/src/server/ios-devices.ts b/src/server/ios-devices.ts index d9cb4f34..f49d1b91 100644 --- a/src/server/ios-devices.ts +++ b/src/server/ios-devices.ts @@ -59,6 +59,22 @@ export async function listIosDevices(signal?: AbortSignal, run: DeviceCommand = })); } +const app = z.object({ bundleIdentifier: text, url: text }); +const appsResponse = z.object({ info, result: z.object({ apps: z.array(app) }) }); + +/** Executable names of a phone's installed apps by bundle ID, read from each bundle's folder name. */ +export async function listIosAppExecutables(udid: string, signal?: AbortSignal, run: DeviceCommand = execute): Promise> { + const args = ["devicectl", "device", "info", "apps", "--device", udid, "--quiet", "--timeout", "10", "--omit-deprecated-fields-in-json", "--json-output", "-"]; + const output = await run("/usr/bin/xcrun", args, { encoding: "utf8", timeout: 15000, maxBuffer: 8 * 1024 * 1024, signal }); + const parsed = appsResponse.parse(JSON.parse(output.stdout)); + const executables = new Map(); + for (const { bundleIdentifier, url } of parsed.result.apps) { + const folder = /\/([^/]+)\.app\/?$/.exec(url)?.[1]; + if (folder) executables.set(bundleIdentifier, decodeURIComponent(folder)); + } + return executables; +} + export function registerIosDeviceTools(server: McpServer, discover: () => Promise = listIosDevices) { const physicalDevices = z.array(physicalIosDeviceSchema); const failures = new FailureEpisodes(); diff --git a/src/server/log-tools.ts b/src/server/log-tools.ts index 3aa9bd38..685307d9 100644 --- a/src/server/log-tools.ts +++ b/src/server/log-tools.ts @@ -70,10 +70,11 @@ export function registerLogTools(server: McpServer, logs: LogSessions, baguette: })); registerAppTool(server, "mobile_crashes_session", { - title: "Watch app crashes", description: "Watch one iOS simulator's crash reports or one Android device's fatal exceptions for the panel's crash notices. Read batches like a log session.", + title: "Watch app crashes", description: "Watch one iOS simulator's or connected iPhone's crash reports, or one Android device's fatal exceptions, for the panel's crash notices. Read batches like a log session.", inputSchema: { target: crashWatchTargetSchema }, annotations, _meta: { ui: { visibility: ["app"] } }, }, safe(async ({ target }: { target: CrashWatchTarget }) => { - if (target.platform === "ios") await baguette.device(target.deviceId, true); + if (target.platform === "ios" && target.kind === "physical") await physicalIosLogDevice(target.deviceId, discoverIosDevices); + else if (target.platform === "ios") await baguette.device(target.deviceId, true); else if (!(await listAndroidLogDevices()).some(device => device.id === target.deviceId)) { throw new ExpectedOperationError("device_unavailable", "The selected Android device is absent or unauthorized."); } diff --git a/src/server/physical-ios-logs.ts b/src/server/physical-ios-logs.ts index 3e2caa6a..1037763d 100644 --- a/src/server/physical-ios-logs.ts +++ b/src/server/physical-ios-logs.ts @@ -1,10 +1,15 @@ import { ExpectedOperationError } from "../shared/error-reporting.ts"; +import { execFile } from "node:child_process"; import { access } from "node:fs/promises"; import { constants } from "node:fs"; import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { z } from "zod"; import type { NativeLogTarget } from "../shared/logs.ts"; import { listIosDevices } from "./ios-devices.ts"; +const execute = promisify(execFile); + export type PhysicalIosLogTarget = Extract; export async function physicalIosLogDevice(udid: string, discover = listIosDevices, signal?: AbortSignal) { @@ -18,16 +23,39 @@ export async function physicalIosLogDevice(udid: string, discover = listIosDevic return device; } -export async function physicalIosLogCommand(target: PhysicalIosLogTarget, signal?: AbortSignal, discover = listIosDevices, - helper = new URL("./ios-logs/mobile-dev-ios-logs", import.meta.url)) { +const bundledReader = new URL("./ios-logs/mobile-dev-ios-logs", import.meta.url); + +async function physicalIosReader(udid: string, signal?: AbortSignal, discover = listIosDevices, helper = bundledReader) { if (process.platform !== "darwin") throw new ExpectedOperationError("unsupported_platform", "Physical iOS logs require macOS."); - const device = await physicalIosLogDevice(target.deviceId, discover, signal); + const device = await physicalIosLogDevice(udid, discover, signal); const command = fileURLToPath(helper); try { await access(command, constants.X_OK); } catch { throw new Error("The bundled physical iOS log reader is missing. Rebuild and package the plugin."); } signal?.throwIfAborted(); const transport = device.transportType === "localNetwork" ? "network" : "usb"; - const args = ["--device", device.udid, transport]; - if (target.process) args.push(target.process); - return { command, args }; + return { command, args: ["--device", device.udid, transport] }; +} + +export async function physicalIosLogCommand(target: PhysicalIosLogTarget, signal?: AbortSignal, discover = listIosDevices, helper = bundledReader) { + const reader = await physicalIosReader(target.deviceId, signal, discover, helper); + if (target.process) reader.args.push(target.process); + return reader; +} + +const crashReportLine = z.object({ name: z.string(), modified: z.number(), text: z.string() }); +export type PhysicalIosCrashReport = z.infer; + +/** Copy the phone's .ips crash reports modified at or after sinceMs, in the phone's clock. */ +export async function physicalIosCrashReports(udid: string, sinceMs: number, signal?: AbortSignal, discover = listIosDevices, helper = bundledReader): Promise { + const reader = await physicalIosReader(udid, signal, discover, helper); + const { stdout } = await execute(reader.command, [...reader.args, "--crash-reports", String(Math.max(0, Math.floor(sinceMs)))], + { timeout: 30000, maxBuffer: 64 * 1024 * 1024, signal }); + const reports: PhysicalIosCrashReport[] = []; + for (const line of stdout.split("\n")) { + let value: unknown; + try { value = JSON.parse(line); } catch { continue; } + const parsed = crashReportLine.safeParse(value); + if (parsed.success) reports.push(parsed.data); + } + return reports; } diff --git a/src/server/telemetry.ts b/src/server/telemetry.ts index 7343545c..001e7ee1 100644 --- a/src/server/telemetry.ts +++ b/src/server/telemetry.ts @@ -108,12 +108,15 @@ export function recordAndroidBackendStop(duration: number, context: AndroidStart Sentry.metrics.gauge("android.backend.process_shutdown.duration", duration, { unit: "millisecond", attributes }); } -/** Count a detected crash. Only the .ips report has a parse cost worth measuring. */ -export function recordCrash(source: "ios_exit" | "ios_log" | "ios_report" | "android_logcat", parseDuration?: number) { +/** Count one detected crash. iOS sources carry the device kind; reportWait is exit-to-report time on a physical iPhone. */ +export function recordCrash(source: "ios_exit" | "ios_log" | "ios_report" | "android_logcat", details: { parse?: number; deviceKind?: "simulator" | "physical"; reportWait?: number } = {}) { if (process.env.MOBILE_DEV_TELEMETRY === "off") return; - const attributes = { component: "server", surface: "logs", device_platform: source === "android_logcat" ? "android" : "ios", crash_source: source }; + const ios = source !== "android_logcat"; + const attributes = { component: "server", surface: "logs", device_platform: ios ? "ios" : "android", crash_source: source, + ...(ios ? { device_kind: details.deviceKind ?? "simulator" } : {}) }; Sentry.metrics.count("logs.crash.detected", 1, { attributes }); - if (parseDuration !== undefined) Sentry.metrics.gauge("logs.crash.parse", parseDuration, { unit: "millisecond", attributes }); + if (details.parse !== undefined) Sentry.metrics.gauge("logs.crash.parse", details.parse, { unit: "millisecond", attributes }); + if (details.reportWait !== undefined) Sentry.metrics.gauge("logs.crash.report_wait", details.reportWait, { unit: "millisecond", attributes }); } export class IOSLogProcessingTelemetry { diff --git a/src/shared/logs.ts b/src/shared/logs.ts index 27ff7e89..5389906a 100644 --- a/src/shared/logs.ts +++ b/src/shared/logs.ts @@ -22,7 +22,8 @@ export const metroLogTargetSchema = z.object({ url: z.string().max(2048), target export const logOptionsSchema = z.object({ native: nativeLogTargetSchema.optional(), metro: metroLogTargetSchema.optional() }) .strict().refine(value => value.native || value.metro, "Choose a native device or Metro target."); export const crashWatchTargetSchema = z.union([ - z.object({ platform: z.literal("ios"), deviceId: udidSchema }).strict(), + z.object({ platform: z.literal("ios"), kind: z.literal("simulator").optional(), deviceId: udidSchema }).strict(), + z.object({ platform: z.literal("ios"), kind: z.literal("physical"), deviceId: physicalUdid }).strict(), z.object({ platform: z.literal("android"), deviceId: z.string().regex(/^[a-zA-Z0-9._:-]{1,128}$/) }).strict(), ]); export type CrashWatchTarget = z.infer; diff --git a/src/shared/version.ts b/src/shared/version.ts index cf6eeaec..bb9747e8 100644 --- a/src/shared/version.ts +++ b/src/shared/version.ts @@ -1 +1 @@ -export const PLUGIN_VERSION = "0.1.161"; +export const PLUGIN_VERSION = "0.1.162"; diff --git a/src/ui/crash-monitor.ts b/src/ui/crash-monitor.ts index 50685207..a3debd4a 100644 --- a/src/ui/crash-monitor.ts +++ b/src/ui/crash-monitor.ts @@ -14,10 +14,12 @@ type Snapshot = { }; function crashTarget(device?: SimulatorDevice): CrashWatchTarget | undefined { - if (!device || device.state !== "Booted") return; + if (!device) return; + if (device.platform === "ios" && device.kind === "physical") { + return device.state === "connected" ? { platform: "ios", kind: "physical", deviceId: device.udid } : undefined; + } + if (device.state !== "Booted") return; if (device.platform === "android") return { platform: "android", deviceId: device.udid }; - // Physical iPhones keep crash reports on the device. - if (device.kind === "physical") return; return { platform: "ios", deviceId: device.udid }; } @@ -110,8 +112,9 @@ export class CrashMonitor { if (sameProcess && !crash.stack) return; clearTimeout(this.reportTimer); const reportPending = crash.origin === "ios" && !crash.stack; + // A physical iPhone's report is copied off the phone, so the server keeps looking for up to two minutes. if (reportPending) { - this.reportTimer = setTimeout(() => this.update({ reportPending: false }), 60000); + this.reportTimer = setTimeout(() => this.update({ reportPending: false }), 120000); } if (sameProcess) { countUiEvent("ui.crash.report_attached"); diff --git a/tests/crash-reports.test.ts b/tests/crash-reports.test.ts index 83d82501..1cc8a945 100644 --- a/tests/crash-reports.test.ts +++ b/tests/crash-reports.test.ts @@ -4,7 +4,8 @@ import { mkdtemp, rm, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; -import { AndroidCrashCollector, crashWatchFor, iosCrashLogPredicate, parseIosAppExit, parseIosCrashLog, parseIosCrashReport, watchIosCrashReports } from "../src/server/crash-reports.ts"; +import { AndroidCrashCollector, crashWatchFor, iosCrashLogPredicate, parseIosAppExit, parseIosCrashLog, parseIosCrashReport, watchIosCrashReports, watchPhysicalIosCrashes } from "../src/server/crash-reports.ts"; +import { listIosAppExecutables } from "../src/server/ios-devices.ts"; import { parseLogcat } from "../src/server/log-parsers.ts"; import { formatLogContext, logKey } from "../src/shared/logs.ts"; import type { CrashWatchTarget, LogRecord, StackedLog } from "../src/shared/logs.ts"; @@ -56,6 +57,17 @@ test("iOS crash reports from other simulators, the host, or partial writes are i assert.equal(parseIosCrashReport(ipsReport().slice(0, 300), udid), undefined); }); +const phoneUdid = "00008110-001A2B3C4D5E6F70"; +const phoneReport = (pid = 4242) => ipsReport(udid, { pid, procPath: "/private/var/containers/Bundle/Application/ABC/Example.app/Example" }); + +test("physical iPhone crash reports parse without a simulator path", () => { + const record = parseIosCrashReport(phoneReport())!; + assert.equal(record.appId, "com.example.app"); + assert.equal(record.process, "Example"); + assert.match(record.stack!, /Thread 0 crashed/); + assert.equal(parseIosCrashReport(phoneReport(), udid), undefined); +}); + test("the simulator crash watcher reports new matching reports once and stops on abort", async () => { const directory = await mkdtemp(join(tmpdir(), "mobile-dev-crashes-")); const controller = new AbortController(); @@ -194,8 +206,8 @@ test("log sessions watch crashes for the same app or process as their native tar emit!({ ...crash, appId: "com.other.app" }); emit!({ ...crash, appId: "com.example.app" }); assert.deepEqual((await logs.read(id, 0, 0)).entries.map(entry => entry.appId), ["com.example.app"]); - logs.open({ native: { platform: "ios", kind: "physical", deviceId: "00008110-001A2B3C4D5E6F70" } }); - assert.equal(targets.length, 1); + logs.open({ native: { platform: "ios", kind: "physical", deviceId: phoneUdid } }); + assert.deepEqual(targets[1], { platform: "ios", kind: "physical", deviceId: phoneUdid }); } finally { await logs.close(); } const ios = crashWatchFor({ platform: "ios", deviceId: "C6C04008-45B0-431B-9BA8-96BE5BE99C56", process: "Expo Go" })!; assert.equal(ios.includes({ timestamp: "", level: "error", source: "native", origin: "ios", message: "", process: "Expo Go" }), true); @@ -208,7 +220,8 @@ function crashMonitorFixture() { Object.defineProperty(globalThis, "document", { value: visibility, configurable: true }); let emit: ((crash: LogRecord) => void) | undefined; const calls: string[] = []; - const logs = new LogSessions({ native: () => async () => {}, metro: () => async () => {}, crashes: (_target, next) => { emit = next; return async () => {}; } }); + const targets: CrashWatchTarget[] = []; + const logs = new LogSessions({ native: () => async () => {}, metro: () => async () => {}, crashes: (target, next) => { targets.push(target); emit = next; return async () => {}; } }); const app = { async callServerTool(input: { name: string; arguments: { target?: CrashWatchTarget; sessionId?: string } }) { calls.push(input.name); @@ -237,7 +250,7 @@ function crashMonitorFixture() { monitor.connect(app as never, context as unknown as PanelContext); const crash = (message: string, deviceId = "emulator-5554"): LogRecord => ({ timestamp: "2026-10-19T10:00:00Z", level: "error", source: "native", origin: "android", kind: "crash", deviceId, appId: "host.exp.exponent", message, stack: "at main" }); const until = async (condition: () => boolean) => { for (let attempt = 0; attempt < 100 && !condition(); attempt++) await delay(10); assert.ok(condition()); }; - return { monitor, context, calls, logs, crash, until, emit: (record: LogRecord) => emit!(record), ready: () => emit !== undefined }; + return { monitor, context, calls, targets, logs, crash, until, emit: (record: LogRecord) => emit!(record), ready: () => emit !== undefined }; } const emulator = { udid: "emulator-5554", name: "Pixel 9", state: "Booted", runtime: "Android 16", platform: "android", kind: "emulator" } as const; @@ -294,14 +307,18 @@ test("an iOS exit crash shows at once, then gains its stack and keeps an early a } finally { monitor.dispose(); await logs.close(); } }); -test("the crash toast skips physical iPhones and clears when switching devices", async () => { - const { monitor, calls, logs, crash, until, emit, ready } = crashMonitorFixture(); +test("the crash toast watches connected iPhones and clears when switching devices", async () => { + const { monitor, calls, targets, logs, crash, until, emit, ready } = crashMonitorFixture(); + const phone = { udid: phoneUdid, name: "iPhone", state: "connected", runtime: "", platform: "ios", kind: "physical" } as never; try { - monitor.configure({ udid: "00008110-001A2B3C4D5E6F70", name: "iPhone", state: "connected", runtime: "", platform: "ios", kind: "physical" } as never, true); + monitor.configure({ ...(phone as object), state: "disconnected" } as never, true); await delay(20); assert.deepEqual(calls, []); - monitor.configure(emulator, true); + monitor.configure(phone, true); await until(ready); + assert.deepEqual(targets, [{ platform: "ios", kind: "physical", deviceId: phoneUdid }]); + monitor.configure(emulator, true); + await until(() => targets.length === 2); emit(crash("boom")); await until(() => monitor.getSnapshot().crash !== undefined); monitor.configure(emulator, false); @@ -311,3 +328,41 @@ test("the crash toast skips physical iPhones and clears when switching devices", assert.equal(monitor.getSnapshot().crash, undefined); } finally { monitor.dispose(); await logs.close(); } }); +// SpringBoard's exit line as the phone's log relay prints it. +const relayExit = (pid: number) => JSON.stringify({ timestamp: "2026-10-19T10:00:00.250000Z", messageType: "Notice", processID: 34, process: "SpringBoard", + eventMessage: `Process exited: :${pid}(v1A2)> -> >` }); + +test("physical iPhone exits show at once, then pick up the report copied off the phone", async () => { + const controller = new AbortController(); + const records: LogRecord[] = []; + const polls: number[] = []; + const script = `console.log('{"ready":true}'); console.log(${JSON.stringify(relayExit(4242))}); setInterval(() => {}, 1000);`; + const watching = watchPhysicalIosCrashes(phoneUdid, record => records.push(record), controller.signal, { + exits: async target => { assert.equal(target.process, "SpringBoard"); return { command: process.execPath, args: ["-e", script] }; }, + // The first copy has only another process's report; the next one has this crash's. + reports: async (_udid, since) => { polls.push(since); return polls.length === 1 ? [{ name: "a.ips", modified: 0, text: phoneReport(1) }] : [{ name: "b.ips", modified: 0, text: phoneReport() }]; }, + executables: async () => new Map([["com.example.app", "Example"]]), + }, 10); + try { + for (let attempt = 0; attempt < 200 && records.length < 2; attempt++) await delay(10); + assert.equal(records.length, 2); + assert.deepEqual({ ...records[0], timestamp: undefined }, { timestamp: undefined, level: "error", source: "native", origin: "ios", kind: "crash", + appId: "com.example.app", pid: 4242, process: "Example", reason: "SIGABRT", message: "Example crashed: SIGABRT" }); + assert.equal(records[0].timestamp, "2026-10-19T10:00:00.250Z"); + assert.equal(polls[0], Date.parse("2026-10-19T10:00:00.250Z") - 5000); + assert.equal(records[1].pid, 4242); + assert.match(records[1].stack!, /Thread 0 crashed/); + } finally { controller.abort(); await watching; } +}); + +test("installed iPhone apps map bundle IDs to executable names", async () => { + const stdout = JSON.stringify({ info: { jsonVersion: 5, outcome: "success" }, result: { apps: [ + { bundleIdentifier: "com.example.app", url: "file:///private/var/containers/Bundle/Application/ABC/Pump%20Party.app/" }, + { bundleIdentifier: "com.example.odd", url: "file:///private/var/containers/Bundle/Application/DEF/" }, + ] } }); + const executables = await listIosAppExecutables(phoneUdid, undefined, async (_file, args) => { + assert.deepEqual(args.slice(0, 6), ["devicectl", "device", "info", "apps", "--device", phoneUdid]); + return { stdout }; + }); + assert.deepEqual([...executables], [["com.example.app", "Pump Party"]]); +}); diff --git a/tests/physical-ios-logs.test.ts b/tests/physical-ios-logs.test.ts index c438d41b..1fb5c689 100644 --- a/tests/physical-ios-logs.test.ts +++ b/tests/physical-ios-logs.test.ts @@ -4,7 +4,7 @@ import { execFile } from "node:child_process"; import childProcess from "node:child_process"; import { syncBuiltinESMExports } from "node:module"; import { promisify } from "node:util"; -import { mkdtemp, rm } from "node:fs/promises"; +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; @@ -14,7 +14,7 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { nativeLogTargetSchema } from "../src/shared/logs.ts"; import type { LogRecord, LogSourceStatus } from "../src/shared/logs.ts"; import type { PhysicalIosDevice } from "../src/shared/ios-devices.ts"; -import { physicalIosLogCommand } from "../src/server/physical-ios-logs.ts"; +import { physicalIosCrashReports, physicalIosLogCommand } from "../src/server/physical-ios-logs.ts"; import type { PhysicalIosLogTarget } from "../src/server/physical-ios-logs.ts"; import { startNativeLogs } from "../src/server/native-logs.ts"; import { LogSessions } from "../src/server/log-sessions.ts"; @@ -65,6 +65,18 @@ test("physical readers reuse discovery UDIDs and select its current USB or Wi-Fi await assert.rejects(absent, /no longer connected/); }); +test("physical crash reports come from the bundled reader's report mode", { skip: process.platform !== "darwin" }, async () => { + const directory = await mkdtemp(join(tmpdir(), "mobile-dev-crash-reader-")); + try { + const helper = join(directory, "reader"); + const report = JSON.stringify({ name: "Example-2026-10-19-120000.ips", modified: 1760868000000, text: "{}\n{}" }); + await writeFile(helper, `#!/bin/sh\n[ "$*" = "--device ${phone.udid} network --crash-reports 1760867995000" ] || exit 3\nprintf '%s\\n' '${report}' 'not json'\n`); + await chmod(helper, 0o755); + const reports = await physicalIosCrashReports(phone.udid, 1760867995000.5, undefined, async () => [{ ...phone, transportType: "localNetwork" }], pathToFileURL(helper)); + assert.deepEqual(reports, [JSON.parse(report)]); + } finally { await rm(directory, { recursive: true, force: true }); } +}); + test("closing physical logs cancels pending discovery without spawning a reader", { skip: process.platform !== "darwin", timeout: 1000 }, async t => { const spawned = t.mock.method(childProcess, "spawn", () => { throw new Error("A cancelled session must not spawn a reader."); }); syncBuiltinESMExports(); diff --git a/tests/telemetry.test.ts b/tests/telemetry.test.ts index cbd7bf2c..52ff6a76 100644 --- a/tests/telemetry.test.ts +++ b/tests/telemetry.test.ts @@ -11,7 +11,7 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { z } from "zod"; import { MeasurementWindow, sampleTrace, scrubErrorEvent, scrubMetric, scrubSpan, TELEMETRY_META_KEY } from "../src/shared/telemetry.ts"; -import { captureServerError, installTracePropagation, instrumentMcpServer, IOSLogProcessingTelemetry, recordAndroidBackendStartup, recordPluginUpdate, recordIosMirrorSharing } from "../src/server/telemetry.ts"; +import { captureServerError, installTracePropagation, instrumentMcpServer, IOSLogProcessingTelemetry, recordAndroidBackendStartup, recordCrash, recordPluginUpdate, recordIosMirrorSharing } from "../src/server/telemetry.ts"; import { ExpectedOperationError, FailureEpisodes } from "../src/shared/error-reporting.ts"; import { parseResourceInput } from "../src/server/resource-input.ts"; import { openRequestSession } from "../src/server/request-session.ts"; @@ -185,6 +185,31 @@ test("plugin update metrics measure plugin work without device context and honor assert.equal(afterOptOut, sent); }); +test("crash metrics separate simulators from physical iPhones and honor opt-out", async t => { + const envelopes: Envelope[] = []; + Sentry.init({ + dsn: "https://public@example.com/1", defaultIntegrations: false, beforeSendMetric: scrubMetric, + transport: () => ({ async send(envelope) { envelopes.push(envelope); return { statusCode: 200 }; }, async flush() { return true; } }), + }); + const previous = process.env.MOBILE_DEV_TELEMETRY; + delete process.env.MOBILE_DEV_TELEMETRY; + t.after(async () => { + if (previous === undefined) delete process.env.MOBILE_DEV_TELEMETRY; + else process.env.MOBILE_DEV_TELEMETRY = previous; + await Sentry.close(); + }); + recordCrash("ios_exit"); + recordCrash("ios_report", { parse: 4, deviceKind: "physical", reportWait: 31000 }); + recordCrash("android_logcat"); + await Sentry.flush(); + const sent = JSON.stringify(envelopes); + for (const fragment of ["logs.crash.detected", "logs.crash.parse", "logs.crash.report_wait", '"device_kind":{"value":"simulator"', '"device_kind":{"value":"physical"', '"crash_source":{"value":"android_logcat"']) contains(sent, fragment); + process.env.MOBILE_DEV_TELEMETRY = "off"; + recordCrash("ios_exit", { deviceKind: "physical" }); + await Sentry.flush(); + assert.equal(JSON.stringify(envelopes), sent); +}); + test("shared iOS capture metrics retain surface attribution and honor opt-out", async t => { const envelopes: Envelope[] = []; Sentry.init({ diff --git a/vendor/ios-logs/libcrypto.3.dylib b/vendor/ios-logs/libcrypto.3.dylib index a8ef9742..9efa0f85 100644 Binary files a/vendor/ios-logs/libcrypto.3.dylib and b/vendor/ios-logs/libcrypto.3.dylib differ diff --git a/vendor/ios-logs/libimobiledevice-1.0.6.dylib b/vendor/ios-logs/libimobiledevice-1.0.6.dylib index 08905519..0377358f 100755 Binary files a/vendor/ios-logs/libimobiledevice-1.0.6.dylib and b/vendor/ios-logs/libimobiledevice-1.0.6.dylib differ diff --git a/vendor/ios-logs/libimobiledevice-glue-1.0.0.dylib b/vendor/ios-logs/libimobiledevice-glue-1.0.0.dylib index f61fda62..3b26b0bd 100755 Binary files a/vendor/ios-logs/libimobiledevice-glue-1.0.0.dylib and b/vendor/ios-logs/libimobiledevice-glue-1.0.0.dylib differ diff --git a/vendor/ios-logs/libplist-2.0.12.dylib b/vendor/ios-logs/libplist-2.0.12.dylib index d61d60bd..73852e22 100755 Binary files a/vendor/ios-logs/libplist-2.0.12.dylib and b/vendor/ios-logs/libplist-2.0.12.dylib differ diff --git a/vendor/ios-logs/libssl.3.dylib b/vendor/ios-logs/libssl.3.dylib index a1248ba5..664efa93 100644 Binary files a/vendor/ios-logs/libssl.3.dylib and b/vendor/ios-logs/libssl.3.dylib differ diff --git a/vendor/ios-logs/libtatsu.0.dylib b/vendor/ios-logs/libtatsu.0.dylib index 41751bdd..008d6091 100755 Binary files a/vendor/ios-logs/libtatsu.0.dylib and b/vendor/ios-logs/libtatsu.0.dylib differ diff --git a/vendor/ios-logs/libusbmuxd-2.0.7.dylib b/vendor/ios-logs/libusbmuxd-2.0.7.dylib index de805fa5..07b0076c 100755 Binary files a/vendor/ios-logs/libusbmuxd-2.0.7.dylib and b/vendor/ios-logs/libusbmuxd-2.0.7.dylib differ diff --git a/vendor/ios-logs/mobile-dev-ios-logs b/vendor/ios-logs/mobile-dev-ios-logs index 7c2d338f..a25c85ab 100755 Binary files a/vendor/ios-logs/mobile-dev-ios-logs and b/vendor/ios-logs/mobile-dev-ios-logs differ diff --git a/vendor/ios-logs/release.json b/vendor/ios-logs/release.json index f022ab84..b6556552 100644 --- a/vendor/ios-logs/release.json +++ b/vendor/ios-logs/release.json @@ -1,7 +1,7 @@ { "name": "mobile-dev-ios-logs", "target": "darwin-arm64", - "sourceSHA256": "c47ec02574d77cdcb963955bf1ee491139bb5052c308e8fef6ee3eaa56581b64", + "sourceSHA256": "ad67281cbbe499b25cf7ae2bf220a544e3a4f3f5f917b221c03f5f2f3d160ef8", "sources": [ { "name": "libplist", @@ -34,39 +34,39 @@ "sha256": "23cc0077e221c7d991bd0eb02150a0d49199bcca1ddf059edccee9ffd914939d" } ], - "openssl": "OpenSSL 3.6.2 7 Apr 2026 (Library: OpenSSL 3.6.2 7 Apr 2026)", + "openssl": "OpenSSL 3.6.3 9 Jun 2026 (Library: OpenSSL 3.6.3 9 Jun 2026)", "binaries": { "mobile-dev-ios-logs": { - "sha256": "eb09c5d9e85cf462ee59cd8a192e83f3c15dad442d027896f12a6fb6b554d0d1", - "bytes": 384944 + "sha256": "21097f489d8169c0c997c1fd21c26f023794840cf2a1478ea26277c908c0d6a3", + "bytes": 385536 }, "libimobiledevice-1.0.6.dylib": { - "sha256": "229bb457ff0b3b701045ed124e3fdbce0e2115daf01b163735dcfba69c765927", + "sha256": "6a10d929ff31c95f6e51d2ee1c57392cff4125e2a318cb2b30255137c29562ab", "bytes": 278608 }, "libimobiledevice-glue-1.0.0.dylib": { - "sha256": "e2fa173219ed5b6352d76232556b499cdac255cf28b0e277a8867e6b7a1615b8", + "sha256": "afbd4e3baa9920ff66d5fadc6cf0dc1101a546c0225e816516d5ab9538894324", "bytes": 79568 }, "libplist-2.0.12.dylib": { - "sha256": "205279316a58b20e84495fdfdb8d9f860e5197e1602bfd7e80f75a7bcced1ee6", + "sha256": "8b04d03f707f0e325f92763e4b329454a595bbb2479f49776a856826535ee1f4", "bytes": 114976 }, "libtatsu.0.dylib": { - "sha256": "3a85f550bca667dc9ff46ccb72fe716b33b9f13126d34dfb0bc88f6176fd2a1f", + "sha256": "b3432f9b8e3d45284fc7f041c8c7f16e585e243571c04c397eb0421c27455034", "bytes": 55408 }, "libusbmuxd-2.0.7.dylib": { - "sha256": "fad3c8ea428fe603b894133d64610921ddd573e7951a9670b11ea54b03fd584b", + "sha256": "5c429ac0a1673205f88b91b570fc51fe8eb7920b12b1b4299e8463e7a77af0d6", "bytes": 71984 }, "libssl.3.dylib": { - "sha256": "11291c1d6f0527e20448dc06d5bc93f839f33f22a552cf24d6edbd0d1160d158", - "bytes": 866576 + "sha256": "e5b125734246020bbd812e2f7dd2ea19e302608d15ac44ae8bf36ba73799c8fb", + "bytes": 882816 }, "libcrypto.3.dylib": { - "sha256": "fe33b1fe402dc6f540edb1b10e5619f9ddc3dfa90d0ae977c0e0843019495eba", - "bytes": 4827728 + "sha256": "7b074ac6ddc685fe2b2c69669eb1ce9797517e53a3913830b58ee5e8238c52b9", + "bytes": 4842528 } } } diff --git a/vendor/ios-logs/third-party-licenses.txt b/vendor/ios-logs/third-party-licenses.txt index b76e5d38..03b7b8ce 100644 --- a/vendor/ios-logs/third-party-licenses.txt +++ b/vendor/ios-logs/third-party-licenses.txt @@ -2219,7 +2219,7 @@ Public License instead of this License. ==================== -OpenSSL 3.6.2 7 Apr 2026 (Library: OpenSSL 3.6.2 7 Apr 2026) +OpenSSL 3.6.3 9 Jun 2026 (Library: OpenSSL 3.6.3 9 Jun 2026) https://www.openssl.org/