feat(desktop): Mobile pane for local iOS simulators and Android emulators
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,rotateLocalDeviceandrestartLocalHubare new host-service procedures. The hub's device API sends no CORS headers, so the renderer reaches it through these.status,limrunSessionandlocalSessionkeep their shapes.apps/desktop/src/main/lib/mobile-hub-origin.tspresents the renderer's requests to the hub (127.0.0.1,/vendor/serve-simand/vendor/serve-emuonly) 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.ResourceSparklinemoved from the Usage settings page to_authenticated/componentsand takes any timed sample, so the Tools panel reuses it.
Workarounds for upstream behaviour, each with a comment at the site:
| Problem | Workaround |
|---|---|
| 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:
- Pane, panels and click-through results: https://app.superset.sh/page/mobile-pane-our-own-ui-over-expo-s-device-hub-vudi22
- Side by side with Expo's preview: https://app.superset.sh/page/mobile-pane-vs-expo-serve-sim-4is214
| Check | iOS | Android |
|---|---|---|
| Boot from the picker to a live stream | pass | pass |
| Tap, keyboard typing | pass | tap only |
| Home, Screenshot, Rotate both ways | pass | pass (adb) |
| Back, Recent apps | n/a | pass (adb) |
| Tools: dropdowns, text-size slider, switches | pass (simctl) | text size only (adb); panel renders with live data |
| Logs: stream, pause, filter, levels, scope, copy, download, clear, resize | pass | stream and columns only |
| Full screen: enter, Esc, button | pass | pass |
| Shut down, then boot again | pass | not run |
| Simulator rebooted outside the pane, then Reconnect | pass | not 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;
disposeis not reached on that path. - The private temp dirs are not cleaned up.
- Not tested: a packaged build (whether
npxis on host-service'sPATH), narrow panes, physical Android devices.
Checklist
- PR title follows conventional commits (
type(scope): subject) -
bun run lintandbun run typecheckpass (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.tsonly rewrites theOriginheader 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-clientis 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.ResourceSparklinemoved 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.