feat(desktop): Mobile pane for local iOS simulators and Android emulators

Merged
SA
17d ago
+4985-149

What & why

The Mobile pane from #8087 embedded an unpinned npx @expo/serve-sim page in a <webview>. On a real machine that meant iOS only, a 20 s timeout whenever no simulator was already booted, and an Expo UI we could not style. Android needed a hand-configured SUPERSET_ANDROID_MIRROR_CMD and could not be reached on a Mac that has Xcode.

This replaces the local path:

  • Backend: host-service runs a pinned expo-device-hub@0.14.0, which serves iOS simulators and Android emulators together.
  • Pane: our own UI on Expo's headless client (@expo/hub-client, MIT). Only the device screen is Expo's component.
    • Device picker listing every simulator and emulator; choosing a shut-down one boots it. Shut down lives in the same menu.
    • Device keys under the screen: Home, Screenshot, Rotate, plus Back and Recent apps on Android, and Reload for React Native apps.
    • Tools panel: foreground app details, CPU / memory / network charts, event log, and simulator settings.
    • Logs drawer: time, process and message columns, All/App scope, filter, levels, pause, copy, download, resize.
    • Full screen: hides the controls and lets the device fill the pane.

The cloud (Limrun) path is unchanged.

How it works

  • mobile.localDevices, bootLocalDevice, shutdownLocalDevice, rotateLocalDevice and restartLocalHub are new host-service procedures. The hub's device API sends no CORS headers, so the renderer reaches it through these. status, limrunSession and localSession keep their shapes.
  • apps/desktop/src/main/lib/mobile-hub-origin.ts presents the renderer's requests to the hub (127.0.0.1, /vendor/serve-sim and /vendor/serve-emu only) as same-origin. The hub serves logs, settings and its control socket to its own origin only. This rewrites request headers in the main process and is the part I would most like reviewed.
  • ResourceSparkline moved from the Usage settings page to _authenticated/components and takes any timed sample, so the Tools panel reuses it.

Workarounds for upstream behaviour, each with a comment at the site:

ProblemWorkaround
The library's Rotate cycles through an orientation an iPhone refuses, so it sticks in landscape. On Android its request is blocked cross-origin.Rotate sends an explicit portrait / landscape through host-service.
The hub's default Android capture keeps a portrait frame after the device turns.The hub runs with --stream-source scrcpy.
The hub serves a frozen frame for a simulator it has seen shut down.host-service restarts the hub before re-booting such a simulator; a Reconnect button appears after 10 s for simulators rebooted outside the pane.
serve-sim keeps state in the shared temp dir, and any other serve-sim on the machine clears it.The hub gets a private TMPDIR.
@expo/hub-client drops log time, process and level, and its Android reader does not match this hub's event format.The drawer reads the hub's log streams directly.
GUI-launched apps do not inherit ANDROID_HOME.host-service looks in the usual SDK locations and passes it on.

@expo/hub-client is pinned to 1.2.0: it was published with hub 0.14.0, and the repo's 3-day release-age rule blocks the newer releases today.

How I tested it

Driven with real input over CDP in the dev app, on an iPhone simulator (iOS 26.0) and a Pixel 8 emulator (Android 15). Screenshots:

CheckiOSAndroid
Boot from the picker to a live streampasspass
Tap, keyboard typingpasstap only
Home, Screenshot, Rotate both wayspasspass (adb)
Back, Recent appsn/apass (adb)
Tools: dropdowns, text-size slider, switchespass (simctl)text size only (adb); panel renders with live data
Logs: stream, pause, filter, levels, scope, copy, download, clear, resizepassstream and columns only
Full screen: enter, Esc, buttonpasspass
Shut down, then boot againpassnot run
Simulator rebooted outside the pane, then Reconnectpassnot run

Commands run locally: bun run lint, bun run check:i18n, bunx sherif, bun run check:plugins, the desktop test suite (7965 pass), the i18n tests (166 pass) and the host-service router tests (980 pass).

bun run typecheck does not pass on my machine: it reports better-auth type errors in packages/trpc and its dependants. None are in files this PR touches, and main is green in CI at the commit merged here, so I believe it is my local install; CI is the check for this one.

Not covered

  • Rotate does nothing visible while a portrait-locked app is in front. The device does turn.
  • The Tools panel can show the previous app after Home; the hub does not report a return to the home screen.
  • Not built from Expo's panel: network requests, AX tree, camera, location, permissions, display, stream settings, crashes.
  • A dev restart can leave the previous hub process running; dispose is not reached on that path.
  • The private temp dirs are not cleaned up.
  • Not tested: a packaged build (whether npx is on host-service's PATH), narrow panes, physical Android devices.

Checklist

  • PR title follows conventional commits (type(scope): subject)
  • bun run lint and bun run typecheck pass (lint passes; typecheck: see above)

Summary by cubic

The Mobile pane previously embedded an unpinned serve-sim page in a <webview>, giving iOS only and a 20s timeout whenever no simulator was already booted. It now runs a pinned expo-device-hub 0.17.0 from host-service and renders the device in our own UI through @expo/hub-client@1.6.0, serving iOS simulators and Android emulators together. The cloud (Limrun) path is unchanged.

Implementation notes

  • Host-service procedures (localDevices, bootLocalDevice, shutdownLocalDevice, rotateLocalDevice, restartLocalHub) proxy the hub's device API to the renderer, with the hub now started under --require-token; host-service reads the token from its startup link so no other local process can drive the simulators, and hands it to the pane for the hub client and log streams.
  • The 0.17.0 hub answers cross-origin HTTP itself, so mobile-hub-origin.ts only rewrites the Origin header on the iOS simulator control socket's WebSocket upgrades, and only for the app's own window, not browser panes; that rewrite is the part that most needs review.
  • iOS simulators are keyed by id because two runtimes can install the same name; the iOS rotate socket has a timeout, and non-JSON hub error replies and log events are skipped.
  • The upgrade removed the need to restart the hub before re-booting a shut-down iOS simulator; the explicit rotate is kept because the client's rotate() steps through upside-down portrait and does nothing on Android.
  • @expo/hub-client is exactly pinned at 1.6.0 and exempted from the release-age rule; host-service shutdown now stops the hub, though a dev restart can still leave the previous hub process and its private temp dirs.
  • ResourceSparkline moved to shared components and takes any timed sample; the Tools panel stops above the device controls so it no longer covers them in a narrow pane.

Summary by CodeRabbit

  • New Features
    • Manage local iOS simulators and Android emulators from the mobile workspace: select, start, shut down, and reconnect devices.
    • View and control device screens with actions such as Home, Back, app reload, screenshot capture, and rotation.
    • Inspect device logs, app details, activity metrics, and simulator or emulator settings. Filter, pause, copy, and download logs.
    • Use translated device-management and accessibility controls across supported languages.
    • Connect to Limrun iOS sessions and view remote device controls.
  • Improvements
    • Device status and connection messages reflect the current device and session state.