sandbox_device

Leases an Android emulator or an iPhone simulator for the box, shown on its screen; lists the box's devices, or releases one.

Input Output
id, action (attach, list, release, reconnect or restart), platform (android or ios), model, osVersion, deviceId the device's deviceId, serial inside the box, model, osVersion, status; attach also returns bootTimings and egressIp; list returns the box's devices and the device hosts; reconnect returns the device with reconnected: true, restart with restarted: true

attach leases a phone for the box, on separate hardware, and waits until it has booted: an Android emulator with platform: "android", an iPhone simulator on a Mac with platform: "ios". Its screen is on the box display, so sandbox_shot, recordings, takeover and a box sandbox_review show it, and the person can tap and type on it there. A box can hold up to 4 devices. release gives one back; stopping the box releases all of them. Devices bill by the minute while attached and running. A phone freezes and thaws with its box (Box states): when the box goes 10 minutes without use, the phone is frozen first, then the box, and a frozen phone holds no device slot and does not bill. An Android emulator is saved with its memory, so it comes back exactly where it was; an iPhone simulator is shut down, so its installed apps, their data and the Mac's copy of /work are kept, but apps start again. The call that thaws the box brings the phone back on the same host and attaches it with the same serial before it runs (about 35 seconds for Android, 50 for an iPhone; both come back at the same time). If that host has no free slot then, the phone stays frozen (list shows status: "frozen") and is attached again as soon as a slot frees up while the box keeps being used (a call in the last 10 minutes); release discards it. list does not wake a frozen box. A box review of it waiting for the person does not keep it up: the phone freezes and is released with the box by the usual idle rules, and the person opening the review wakes both. When every device of that platform is taken, attach fails with all N <platform> device slots are in use; try again later or release a device with sandbox_device release. An osVersion the device hosts lack fails at once with 400 osVersion <v> is not available: the <platform> device host has <version>; attach without osVersion to use it, before anything boots: a phone is never quietly given another version. While it waits, progress messages say which step the phone is at (for an iPhone: creating the lease user, creating the simulator, its first boot, warming up Safari), and the result's bootTimings says how long each step took. egressIp in the result is the IP the phone's own internet traffic leaves from: it is not the box's IP, so check IP allowlists and geolocation against it (the phone has no curl, and curl in the box answers with the box's IP).

list returns { "devices": [...], "hosts": [...] }. devices has the box's phones with their status (ready, frozen, offline, restarting and the like), each with a note when it is not simply ready, and those that stopped working in the last hour with status: "failed" and their reason (an emulator that keeps crashing ends up there; attach a new one). hosts says, for each platform, whether a device host is online, how many freeSlots it has, the osVersions it can boot (pass one of them as osVersion, or leave it out for the default) and egressIps, where a phone's own traffic leaves from; when none is online it adds lastHeartbeatAt and a note: attaching that platform fails until it is back, which is on ParallelSandbox's side, not in your project, so plan the test without that phone or try later. Check it before spending time building for a phone; sandbox_start with toolchains: ["android"] says so in its next when no Android host is online.

When the person should try an Android build and no emulator is available, hand them the APK itself to install on their own phone. There is no file entry in sandbox_review; serve it as a page: put the APK in a folder with an index.html that links it (<a href="app-debug.apk" download>Install</a>), serve that folder in the background (cd /work/apk && python3 -m http.server 8090, background: true), and call sandbox_review with open: "web" and port: 8090, saying in what that they open it on their Android phone, download the APK and allow their browser to install apps when Android asks. A debug build installs this way; a phone with a Play Store build of the same app needs that one uninstalled first, since the signatures differ.

An Android device whose adb in the box no longer reaches it comes back by itself. When its tunnel drops, the box notices within about 20 seconds and reconnects it, trying again every 30 seconds until it is back; when the emulator itself crashes or stops answering adb for a minute, its device host restarts the same emulator in place, keeping its serial, installed apps and their data (a cold boot, about a minute), and the box reconnects to it. Meanwhile list shows it as status: "offline" with its adb state (and, once the box has reconnected it, autoReconnects), or status: "restarting"; a device that was restarted shows restarts, restartedAt and restartReason. sandbox_device { "id": "<id>", "action": "reconnect", "deviceId": "<deviceId>" } reconnects it right away and returns it with reconnected: true. An emulator that crashes a fourth time within 15 minutes is not restarted again and is listed as failed: often the app is what brings it down, so read adb logcat before it goes, then attach a new one.

sandbox_device { "id": "<id>", "action": "restart", "deviceId": "<deviceId>" } restarts a phone in place and returns once it has booted (restarted: true), with the same serial, installed apps and their data; apps that were open start again. An Android emulator reboots (adb reboot, then the box reconnects it, waits for the boot to finish and wakes the screen); an iPhone simulator shuts down and boots again, which also applies settings that only take effect on a new boot, such as simctl ui {udid} appearance dark, and clears a system dialog that will not go away. Boxes started before this action existed answer that their image cannot restart a device.

Android (about 35 seconds): Android 15 on a pixel_9 profile unless you pass model (for example pixel_7). Inside the box adb devices lists it under the returned serial (emulator-5554, then emulator-5556), and during a review or takeover the person can tap and type on it.

The box has artemis-adb, deterministic ADB tools with no model inside: hierarchy --out FILE (a JSON array of on-screen elements with text, content-desc, resource-id, package and parsed_bounds; tap the center of parsed_bounds), tap X Y, long-press X Y, swipe X1 Y1 X2 Y2, type X Y TEXT, clear-text X Y, erase, back, key KEYCODE, launch PACKAGE, stop PACKAGE, open URL, screenshot --out FILE.

adb reverse tcp:PORT tcp:PORT works through the tunnel: the phone's localhost:PORT is that port in the box, so a dev server in the box reaches the app the way Metro reaches React Native. Hybrid apps (Capacitor, Cordova, Ionic) built in debug mode, or with WebView.setWebContentsDebuggingEnabled(true), expose their WebView to Chrome DevTools: psbx-webview <package> [port] forwards that app's WebView to 127.0.0.1:<port> in the box (default 9222) and prints each page's webSocketDebuggerUrl (psbx-webview alone lists the apps that have one, psbx-webview chrome is Chrome itself). Connect to a page's URL with raw CDP, chrome-remote-interface or Puppeteer's connect({ browserWSEndpoint: <that URL> }); Playwright's connectOverCDP fails on a WebView with Browser.setDownloadBehavior: Browser context management is not supported. adb exec-out screencap -p > /work/phone.png saves the phone screen at full resolution.

iOS: an iPhone simulator on an Apple silicon Mac with Xcode 26.5, running iOS 26.5 on an iPhone 17 Pro unless you pass model, the name of another simulator device such as "iPhone 17", "iPhone Air", "iPhone 17 Pro Max" or "iPad Pro 11-inch (M5)". list shows the iOS versions the Macs have in hosts[].osVersions; leave osVersion out for the newest. An iPhone takes 1 to 4 minutes to attach, most of it the first boot of a new simulator. serial is the simulator's UDID. The simulator belongs to a macOS user made for this lease alone, and is deleted with everything in it when the device is released, including the Mac's copy of /work and what was built there: after a new attach, sync and build again.

There is no adb for iOS, and Xcode runs only on the Mac, so the app is built there. The box gets psbx-ios on PATH, plus xcodebuild and simctl wrappers that forward to it; run them with sandbox_exec. Each call is an SSH round trip to the Mac (about 3 seconds for the first, under a second after that). The box itself needs no toolchain or extra size for this.

  • Every psbx-ios word runs on the Mac in the lease's work/ folder, a copy of the box's /work that psbx-ios sync fills, at the same paths: the box's /work/app is work/app on the Mac (psbx-ios pwd prints the folder's full path). So give the other words paths relative to /work, xcodebuild -project app/Hello.xcodeproj ... or -workspace app/ios/App/App.xcworkspace, never box paths such as /work/app/...; cd in the box changes nothing there.
  • psbx-ios sync [PATH] copies PATH from the box (default all of /work; a relative path starts from your directory in the box, and a path outside /work lands under its last name) to the same place on the Mac, with or without a trailing slash, and prints progress and the Mac's full path when it is done; run a large one, such as a node_modules, with sandbox_exec and background: true. It deletes what the box no longer has there, so a file deleted in the box goes away on the Mac too, except what is built or recorded on the Mac: dd/ and DerivedData/ folders at any depth and build/, *.mp4 and *.mov at the top of PATH are kept on the Mac and never sent.
  • psbx-ios pull PATH [DEST] copies a file or folder from the Mac's work/ into the box (DEST defaults to /work/), for what only the Mac produced.
  • xcodebuild -project app/Hello.xcodeproj -scheme Hello -destination "platform=iOS Simulator,id={udid}" -derivedDataPath dd CODE_SIGN_IDENTITY=- CODE_SIGN_STYLE=Manual build builds for the leased simulator (about 6 seconds for a small SwiftUI app; {udid} is replaced by this simulator's id, and <serial> works too). The product stays on the Mac, here dd/Build/Products/Debug-iphonesimulator/Hello.app; nothing comes back to the box except each command's output (and what you psbx-ios pull). ls in the box does not see it; psbx-ios ls dd/Build/Products/Debug-iphonesimulator does. Keep CODE_SIGN_IDENTITY=- CODE_SIGN_STYLE=Manual (ad hoc signing, no certificate needed): with CODE_SIGNING_ALLOWED=NO the app runs but its launch screen stays black, because the simulator refuses an unsigned launch storyboard.
  • psbx-ios xcodegen DIR [ARGS] runs XcodeGen in work/DIR on the Mac (default generate), making the .xcodeproj from its project.yml, so a new app needs no hand-written project file: psbx-ios sync app, then psbx-ios xcodegen app.
  • psbx-ios install dd/Build/Products/Debug-iphonesimulator/Hello.app (a path on the Mac; for one that is not there, such as a box path, it says that paths start from the Mac's work/ and to look with psbx-ios ls), psbx-ios launch BUNDLE_ID, psbx-ios terminate BUNDLE_ID, and psbx-ios screenshot /work/phone.png (or > /work/phone.png) for the screen at full resolution (1206 × 2622 on an iPhone 17 Pro). BUNDLE_ID is the target's PRODUCT_BUNDLE_IDENTIFIER: xcodebuild -project app/Hello.xcodeproj -scheme Hello -showBuildSettings | grep PRODUCT_BUNDLE_IDENTIFIER.
  • simctl ARGS runs xcrun simctl ARGS on the Mac with {udid} replaced by this simulator, for example simctl openurl {udid} myapp://orders/42. psbx-ios xcrun ARGS runs other Xcode tools, such as psbx-ios xcrun --sdk iphonesimulator swiftc .... psbx-ios boot, psbx-ios shutdown, psbx-ios ls and psbx-ios pwd work too, and so do the input commands below and psbx-ios help, which lists them all; any other word after psbx-ios is refused with exit code 126 and psbx-lease: 不允許的指令: <word>. simctl privacy {udid} grant photos BUNDLE_ID (also location, contacts, calendar, microphone and the other services simctl privacy lists) answers a permission prompt in advance; notifications are not among them, so tap Allow on that prompt (psbx-ios tap waits for it to finish sliding in) or have a test build skip the request. simctl ui {udid} appearance dark takes effect for apps after a restart of the device.
  • The Xcode project of a Capacitor or React Native app links packages in node_modules by relative path: sync the folder that holds both ios/ and node_modules/ (psbx-ios sync myapp), or the package graph will not resolve.
  • A Flutter app builds for iOS with the Mac's own Flutter, the same version as the box's flutter toolchain: psbx-ios sync app, then psbx-ios flutter app build ios --simulator runs flutter build ios --simulator in app/ on the Mac, and psbx-ios install app/build/ios/iphonesimulator/Runner.app puts it on the simulator. The first psbx-ios flutter of a lease takes a few seconds longer while the lease gets its own copy of the SDK. Plugins that need CocoaPods work too: the Mac has CocoaPods and Node on the PATH of every psbx-ios word.
  • React Native, Expo and Capacitor 6 or older use CocoaPods: sync the folder that holds both ios/ and node_modules/, run psbx-ios pod myapp/ios install (CocoaPods in myapp/ios on the Mac; Node is there for the Podfile and for the Xcode phase that bundles JavaScript), then build the .xcworkspace with xcodebuild. Build React Native with -configuration Release: a debug build loads JavaScript from Metro, and Metro in the box is not reachable from the simulator. For Expo, run npx expo prebuild --platform ios in the box first. Syncing node_modules to the Mac is slow (about 4.5 minutes for a fresh React Native app's 324 MB), so sync it once and later sync only what changed. With an iPhone attached, xcodebuild in the box forwards to the Mac, so npx cap sync ios in the box also calls it and prints that App.xcodeproj does not exist; the web assets are still copied, and psbx-ios pod and xcodebuild -workspace afterwards build normally.
  • Distribution signing is not available: the Mac has no signing identities, so archives for the App Store or TestFlight, and uploads to App Store Connect, are done on the person's own Mac. The Mac also runs only iOS simulators: a macOS desktop app cannot be tested there (the lease has no GUI session).
  • An app's WebView cannot be opened in Safari's Web Inspector from the box. To read what it prints, start the app with simctl launch --console --terminate-running-process {udid} BUNDLE_ID in a sandbox_exec with background: true: the app's stdout and stderr, where Capacitor writes each console.log, go to that command's log until the app stops. Or send messages from the page to a server in the box.
  • Mobile Safari is opened and closed once before attach returns, so its first real launch is not stuck on a black screen; a slow page load still shows only the page, so look with psbx-ios screenshot when in doubt. A Vite dev server serves hundreds of separate modules, and Mobile Safari loads them through the box's URL far slower than Chrome on Android (50 seconds and more on the splash screen): to try a web app on the iPhone, vite build and serve the built files (vite preview) instead.

The box display shows the simulator as a live stream of about 6 to 10 frames a second. The person taps, swipes and types on it during a review or takeover; typing accepts Unicode text, including Chinese and emoji. You can drive it the same way, with coordinates in points (the screen in pixels divided by the scale: 402 × 874 on an iPhone 17 Pro):

  • psbx-ios tap X Y taps. It first waits up to 2 seconds for the screen to stop moving, so it does not land on a transition or on a system dialog still sliding in, then sends the touch the way the person's taps go, which a dialog does not block.
  • psbx-ios swipe X1 Y1 X2 Y2 [SECONDS] swipes, 0.3 seconds unless you pass one.
  • printf 'hello 中文測試🙂' | psbx-ios type types the text on stdin into the focused field, Chinese and emoji included: text with anything beyond ASCII goes through the simulator's Unicode helper by itself. psbx-ios unicode types through that helper only.
  • Add --wait [MS] to tap, swipe, type, unicode, key or button to return only once the screen has not changed for MS milliseconds (default 500), so the next sandbox_shot or psbx-ios screenshot shows the result rather than the frame before it (typing through the Unicode helper returns before the text is on screen): psbx-ios tap 200 400 --wait, printf '你好' | psbx-ios type --wait 800. It prints how long it waited, and returns anyway after 5 more seconds of an animation or video. psbx-ios settle [MS] only waits. A blinking cursor does not count as a change.
  • Keys and typing retry while the screen is mid-transition, waiting for it to settle between tries; when they still fail, the message says a system dialog (a notification permission, a Safari prompt) may be in the way, so look with sandbox_shot.
  • psbx-ios key HIDCODE presses a key: 40 Enter, 42 Backspace, 43 Tab, 41 Escape.
  • psbx-ios button home presses a hardware button: home, lock, side-button, siri or apple-pay.
  • psbx-ios size prints the screen's width and height in pixels and the scale (1206 2622 3.000000).

A deep link (simctl openurl) or a UI test target (xcodebuild test) still reaches a screen faster; check it with sandbox_shot or psbx-ios screenshot. To catch something brief, such as the hand-off from the launch screen to the first screen, psbx-ios record SECONDS FILE records the simulator at its own frame rate for 1 to 120 seconds into an mp4 in the box (psbx-ios record 5 /work/launch.mp4, then ffmpeg -i /work/launch.mp4 -vf fps=20 /work/f_%03d.png for single frames); psbx-ios screenshot takes about 0.7 seconds a picture, too slow for that. sandbox_shot with record records the box screen, the live stream at about 10 frames a second.

To show a native app to the person, first tap and type through it yourself (artemis-adb tap/type on Android, psbx-ios tap/type on iOS, then sandbox_shot), leave it running on the device at its starting screen and call sandbox_review with open: "box". The card opens the box's screen, where they tap and type on the native app themselves. The review keeps nothing awake: the device freezes and is released with the box by the usual idle rules, and comes back with it when the person opens the review.


Every tool and topic is listed in the tool reference.