sandbox_device

為箱子租一台 Android 模擬器或 iPhone 模擬器,顯示在箱子畫面上;也能列出箱子的裝置,或還掉一台。

輸入 輸出
id、action(attach、list、release、reconnect 或 restart)、platform(android 或 ios)、model、osVersion、deviceId 裝置的 deviceId、在箱子裡的 serial、model、osVersion、status;attach 另外回 bootTimings 與 egressIp;list 回這個箱子的 devices 與裝置主機的 hosts;reconnect 回那台裝置並附 reconnected: true,restart 附 restarted: true

attach 替箱子租一支跑在另外機器上的手機,等它開機完成才回:platform: "android" 是 Android 模擬器,platform: "ios" 是 Mac 上的 iPhone 模擬器。畫面在箱子的虛擬螢幕上,所以 sandbox_shot、錄影、接手與 box 的 sandbox_review 都看得到,人也能在那裡點、打字。一個箱子最多同時接 4 台。release 還一台;停掉箱子會全部還回去。裝置開著的期間按分鐘計費。手機跟著箱子凍結與恢復(箱子狀態):箱子 10 分鐘沒有使用時,先凍手機、再凍箱子;凍著的手機不佔裝置格、不計費。Android 模擬器連記憶體一起存下來,開回來就在原本的畫面;iPhone 模擬器是關機,裝好的 App、資料與 Mac 上的 /work 都留著,App 會重新啟動。恢復箱子的那次呼叫會先在同一台主機把手機開回來,接回同一個 serial 再執行(Android 約 35 秒、iPhone 約 50 秒,兩支同時開回來)。那台主機當下沒有空格時,手機照舊凍著(list 顯示 status: "frozen"),箱子還有人在用(10 分鐘內有呼叫)時一有空格就會接回來;release 會丟掉它。list 不會喚醒凍結的箱子。box 的交件在等人時也不會讓它留著:手機照一般閒置規則跟箱子一起凍結、一起回收,人打開交件時會把它跟箱子一起叫醒。那個平台的裝置全被租走時,attach 會失敗並回 all N <platform> device slots are in use; try again later or release a device with sandbox_device release。裝置主機沒有的 osVersion 會在開機前就回 400:osVersion <v> is not available: the <platform> device host has <version>; attach without osVersion to use it,不會默默換成別的版本。等開機的時候,進度訊息會說手機開到哪一步(iPhone 是建租約使用者、建模擬器、第一次開機、預熱 Safari),開好後結果的 bootTimings 列出每一步花了多久。結果的 egressIp 是手機自己上網的出口 IP:跟箱子的出口不同,IP 白名單與地區判斷要對它看(手機裡沒有 curl,在箱子裡 curl 拿到的是箱子的 IP)。

list 回 { "devices": [...], "hosts": [...] }。devices 是這個箱子的手機與它們的 status(ready、frozen、offline、restarting 之類),不是單純 ready 的附一句 note;最近一小時內壞掉的也會列出,status: "failed" 並附 reason(模擬器一直當掉就會這樣;請重新 attach 一台)。hosts 說每個平台有沒有裝置主機 online、還有幾個 freeSlots、開得出哪些 osVersions(osVersion 給其中一個,不給就是預設那一版),以及手機上網的出口 egressIps;一台都不在線時附 lastHeartbeatAt 與 note:那個平台的 attach 會失敗到主機回來為止,這是 ParallelSandbox 這邊的事,不是你的專案的問題,測試計畫先別靠那支手機,或晚點再試。花時間為手機建置之前先看一下;帶 toolchains: ["android"] 開箱時,若沒有 Android 主機在線,sandbox_start 的 next 也會說。

要人試 Android 版、又借不到模擬器時,把 APK 本身交給人裝在自己的手機上。sandbox_review 沒有檔案的入口,所以當成網頁交:把 APK 放進一個資料夾,旁邊放一個連到它的 index.html(<a href="app-debug.apk" download>安裝</a>),在背景起一個伺服器(cd /work/apk && python3 -m http.server 8090,background: true),再用 open: "web"、port: 8090 呼叫 sandbox_review,在 what 寫明用 Android 手機打開、下載 APK、Android 詢問時允許瀏覽器安裝 App。debug 版可以這樣裝;手機上已經有同一個 App 的商店版時,簽章不同,要先解除安裝那一個。

Android 裝置在箱子裡的 adb 連不到時會自己回來。隧道斷了,箱子約 20 秒內發現並重接,沒接回來就每 30 秒再試,直到接回來;模擬器本身當掉、或連著一分鐘不回 adb,裝置主機會在原地把同一台模擬器重開,serial、裝好的 App 與資料都留著(冷開機,約一分鐘),箱子再接回去。這段時間 list 會列成 status: "offline" 並附它的 adb 狀態(箱子自己接回來過的附 autoReconnects),或 status: "restarting";重開過的附 restarts、restartedAt 與 restartReason。sandbox_device { "id": "<id>", "action": "reconnect", "deviceId": "<deviceId>" } 立刻重接,回傳那台裝置並附 reconnected: true。15 分鐘內第四次當掉的模擬器不再重開,會列成 failed:常常是 app 本身把它弄當的,先在它掛掉前看 adb logcat,再 attach 一台新的。

sandbox_device { "id": "<id>", "action": "restart", "deviceId": "<deviceId>" } 在原地重開一支手機,開好才回(restarted: true),serial、裝好的 App 與資料都不變;開著的 App 會重新啟動。Android 模擬器是重開機(adb reboot,箱子接著重接、等開完機、叫醒螢幕);iPhone 模擬器是關機再開機,重開才生效的設定(例如 simctl ui {udid} appearance dark)這時套上,關不掉的系統對話框也一併清掉。這個動作加上之前開的箱子,會回說它的映像不能重開裝置。

Android(約 35 秒):沒給 model 時是 pixel_9 機型的 Android 15(也可以指定,例如 pixel_7)。在箱子裡 adb devices 看得到,名稱就是回傳的 serial(emulator-5554,第二台 emulator-5556);交件或接手時人也能在上面點、打字。

箱子裡有 artemis-adb,是確定性的 ADB 工具,裡面沒有任何模型:hierarchy --out 檔案(畫面上元素的 JSON 陣列,有 text、content-desc、resource-id、package、parsed_bounds;點元素就點 parsed_bounds 的中心)、tap X Y、long-press X Y、swipe X1 Y1 X2 Y2、type X Y 文字、clear-text X Y、erase、back、key KEYCODE、launch 套件名、stop 套件名、open 網址、screenshot --out 檔案。

adb reverse tcp:埠 tcp:埠 穿得過隧道:手機上的 localhost:埠 就是箱子裡的那個埠,箱子裡的 dev server 可以像 Metro 接 React Native 那樣接到 app。debug 版、或呼叫了 WebView.setWebContentsDebuggingEnabled(true) 的混合式 app(Capacitor、Cordova、Ionic)會把 WebView 開給 Chrome DevTools:psbx-webview <package> [埠] 把那個 app 的 WebView 轉到箱子的 127.0.0.1:<埠>(預設 9222),並印出每一頁的 webSocketDebuggerUrl(只打 psbx-webview 列出有 WebView 可接的 app,psbx-webview chrome 是 Chrome 本身)。用 raw CDP、chrome-remote-interface 或 Puppeteer 的 connect({ browserWSEndpoint: <那個網址> }) 接頁面的網址;Playwright 的 connectOverCDP 接 WebView 會失敗:Browser.setDownloadBehavior: Browser context management is not supported。adb exec-out screencap -p > /work/phone.png 存下手機全解析度畫面。

iOS:裝了 Xcode 26.5 的 Apple 晶片 Mac 上的 iPhone 模擬器。沒給 model 時是 iPhone 17 Pro、iOS 26.5,也可以給其他模擬器機型名稱,例如 "iPhone 17"、"iPhone Air"、"iPhone 17 Pro Max" 或 "iPad Pro 11-inch (M5)"。Mac 上有哪些 iOS 版本看 list 的 hosts[].osVersions;不給 osVersion 就是最新的那一版。iPhone 的 attach 要 1 到 4 分鐘,大部分是新模擬器的第一次開機。serial 是模擬器的 UDID。模擬器屬於只給這次租約用的 macOS 使用者,裝置還掉時連同裡面的東西一起刪掉,Mac 上那份 /work 副本和在那裡建置出來的東西也一起沒了:重新 attach 之後要重新同步、重新建置。

iOS 沒有 adb,Xcode 也只能在 Mac 上跑,所以 app 是在 Mac 那邊建置。箱子的 PATH 上會有 psbx-ios,以及轉給它的 xcodebuild、simctl 包裝程式,用 sandbox_exec 執行。每次呼叫都是一趟到 Mac 的 SSH(第一次約 3 秒,之後不到 1 秒)。箱子本身不需要裝工具鏈,也不需要加大 size。

  • 每個 psbx-ios 指令都在 Mac 上這次租約的 work/ 資料夾裡跑,那是 psbx-ios sync 填進去的箱子 /work 副本,路徑一樣:箱子的 /work/app 在 Mac 上就是 work/app(psbx-ios pwd 印出這個資料夾的完整路徑)。所以其他指令的路徑都相對於 /work:寫 xcodebuild -project app/Hello.xcodeproj ... 或 -workspace app/ios/App/App.xcworkspace,不要寫 /work/app/... 這種箱子路徑;箱子裡的 cd 對那邊沒有作用。
  • psbx-ios sync [路徑] 把箱子裡的這個路徑(預設整個 /work;相對路徑從你在箱子裡的目錄算起,/work 以外的路徑用它最後一段的名稱)複製到 Mac 上的同一個位置,結尾有沒有斜線都一樣,做完印出 Mac 上的完整路徑,過程中也印進度;大的(例如 node_modules)用 sandbox_exec 加 background: true 跑。箱子裡已經沒有的東西,Mac 那邊也會刪掉,所以箱子裡刪掉的檔案那邊也會刪掉,只有在 Mac 上建出來或錄下來的不動:任何一層的 dd/ 與 DerivedData/ 資料夾,以及那個路徑最上層的 build/、*.mp4、*.mov,都留在 Mac 上,也不會送過去。
  • psbx-ios pull 路徑 [目的地] 把 Mac 的 work/ 裡的檔案或資料夾複製回箱子(目的地 預設 /work/),拿只有 Mac 才做得出來的東西。
  • xcodebuild -project app/Hello.xcodeproj -scheme Hello -destination "platform=iOS Simulator,id={udid}" -derivedDataPath dd CODE_SIGN_IDENTITY=- CODE_SIGN_STYLE=Manual build 替租到的模擬器建置(小的 SwiftUI app 約 6 秒;{udid} 會換成這台模擬器的 id,寫 <serial> 也行)。產物留在 Mac 上,這個例子是 dd/Build/Products/Debug-iphonesimulator/Hello.app;會回到箱子的只有每個指令的輸出(以及你 psbx-ios pull 回來的)。在箱子裡 ls 看不到它,psbx-ios ls dd/Build/Products/Debug-iphonesimulator 看得到。CODE_SIGN_IDENTITY=- CODE_SIGN_STYLE=Manual(ad hoc 簽章,不需要憑證)要留著:用 CODE_SIGNING_ALLOWED=NO 建的 app 跑得起來,但啟動畫面會一直是黑的,因為模擬器不接受沒簽章的 launch storyboard。
  • psbx-ios xcodegen 資料夾 [參數] 在 Mac 上的 work/資料夾 跑 XcodeGen(預設 generate),用它的 project.yml 產生 .xcodeproj,新做的 app 不用手寫專案檔:psbx-ios sync app,再 psbx-ios xcodegen app。
  • psbx-ios install dd/Build/Products/Debug-iphonesimulator/Hello.app(Mac 上的路徑;給了不存在的路徑,例如箱子的路徑,它會說路徑從 Mac 的 work/ 算起、用 psbx-ios ls 找)、psbx-ios launch BUNDLE_ID、psbx-ios terminate BUNDLE_ID,以及存下全解析度畫面的 psbx-ios screenshot /work/phone.png(或 > /work/phone.png)(iPhone 17 Pro 是 1206 × 2622)。BUNDLE_ID 是 target 的 PRODUCT_BUNDLE_IDENTIFIER:xcodebuild -project app/Hello.xcodeproj -scheme Hello -showBuildSettings | grep PRODUCT_BUNDLE_IDENTIFIER。
  • simctl 參數 在 Mac 上跑 xcrun simctl 參數,其中的 {udid} 換成這台模擬器,例如 simctl openurl {udid} myapp://orders/42。psbx-ios xcrun 參數 可以跑其他 Xcode 工具,例如 psbx-ios xcrun --sdk iphonesimulator swiftc ...。psbx-ios boot、psbx-ios shutdown、psbx-ios ls、psbx-ios pwd、下面的輸入指令,以及列出全部的 psbx-ios help 也可以用;psbx-ios 後面接其他字一律以結束碼 126 拒絕,訊息是 psbx-lease: 不允許的指令: <那個字>。simctl privacy {udid} grant photos BUNDLE_ID(還有 location、contacts、calendar、microphone 等 simctl privacy 列出的服務)可以先答應權限請求;通知不在其中,請在那個對話框點「允許」(psbx-ios tap 會等它滑進來停好),或讓測試版不要請求。simctl ui {udid} appearance dark 要在裝置 restart 之後才套到 app 上。
  • Capacitor 或 React Native app 的 Xcode 專案用相對路徑連到 node_modules 裡的套件:要同步同時包含 ios/ 與 node_modules/ 的那個資料夾(psbx-ios sync myapp),不然套件的相依關係解不出來。
  • Flutter app 的 iOS 版用 Mac 上的 Flutter 建,版本跟箱子的 flutter 工具鏈一樣:psbx-ios sync app 之後,psbx-ios flutter app build ios --simulator 會在 Mac 的 app/ 裡跑 flutter build ios --simulator,再用 psbx-ios install app/build/ios/iphonesimulator/Runner.app 裝到模擬器。一次租約第一次跑 psbx-ios flutter 會多花幾秒,替這次租約複製一份 SDK。要 CocoaPods 的插件也可以:Mac 上每個 psbx-ios 動詞的 PATH 都有 CocoaPods 與 Node。
  • React Native、Expo 與 Capacitor 6 以前用 CocoaPods:同步同時包含 ios/ 與 node_modules/ 的資料夾,跑 psbx-ios pod myapp/ios install(在 Mac 的 myapp/ios 跑 CocoaPods;Podfile 與 Xcode 打包 JavaScript 那一步要的 Node 也在),再用 xcodebuild 建 .xcworkspace。React Native 要用 -configuration Release 建:debug 版會去 Metro 拿 JavaScript,模擬器連不到箱子裡的 Metro。Expo 先在箱子裡跑 npx expo prebuild --platform ios。node_modules 同步到 Mac 很慢(剛建好的 React Native app 324 MB 約 4 分半),同步一次之後只同步改過的部分。接著 iPhone 時箱子裡的 xcodebuild 會轉到 Mac,所以在箱子裡跑 npx cap sync ios 也會叫到它,並印出 App.xcodeproj 不存在;網頁資產照樣有複製,之後 psbx-ios pod 與 xcodebuild -workspace 都能正常建。
  • 不能做發佈用的簽章:Mac 上沒有簽章憑證,App Store 或 TestFlight 的 archive、上傳 App Store Connect 都在使用者自己的 Mac 上做。這台 Mac 也只跑 iOS 模擬器:macOS 桌面 app 在這裡測不了(租約沒有 GUI session)。
  • 箱子沒辦法用 Safari 的 Web Inspector 打開 app 的 WebView。要看它印出來的東西,在 background: true 的 sandbox_exec 裡用 simctl launch --console --terminate-running-process {udid} BUNDLE_ID 啟動 app:app 的 stdout 與 stderr(Capacitor 的每一行 console.log 都寫在這裡)會進那個指令的 log,直到 app 結束。或是從頁面把訊息送到箱子裡的伺服器。
  • attach 回來之前會先開關一次 Mobile Safari,第一次真的開它時不會卡在黑畫面;頁面本身載得慢時畫面上就只有那一頁,不確定時用 psbx-ios screenshot 看。Vite dev server 會送出幾百個獨立的模組,Mobile Safari 經箱子網址載它們比 Android 的 Chrome 慢得多(停在啟動畫面 50 秒以上):要在 iPhone 上試網頁 app,先 vite build,再提供建好的檔案(vite preview)。

箱子的畫面上是模擬器的即時影像,每秒約 6 到 10 格。交件或接手時人在上面點、滑、打字(打字支援中文與 emoji 等 Unicode 文字)。你也能這樣操作,座標用 point(像素除以倍率;iPhone 17 Pro 是 402 × 874):

  • psbx-ios tap X Y 點一下。它會先等畫面停下來(最多 2 秒),不會點在轉場中、或還在滑進來的系統對話框上,再用跟人點畫面一樣的方式送出觸控,對話框擋不住。
  • psbx-ios swipe X1 Y1 X2 Y2 [秒] 滑動,沒給秒數是 0.3 秒。
  • printf 'hello 中文測試🙂' | psbx-ios type 把 stdin 的文字打進目前選取的欄位,中文與 emoji 也行:有 ASCII 以外字元的文字會自己改走模擬器裡的 Unicode helper。psbx-ios unicode 則只走那個 helper。
  • 在 tap、swipe、type、unicode、key、button 後面加 --wait [毫秒],會等畫面連續這麼久(預設 500)沒變才回,接著的 sandbox_shot 或 psbx-ios screenshot 拍到的就是做完的樣子,不是前一格(經 Unicode helper 打字時,指令回來時字還沒上畫面):psbx-ios tap 200 400 --wait、printf '你好' | psbx-ios type --wait 800。它會印出等了多久;遇到動畫或影片,多等 5 秒照樣回。psbx-ios settle [毫秒] 只等不做。游標閃爍不算變化。
  • 按鍵與打字遇到畫面正在轉場會重試,每次之間等畫面停下來;還是失敗時,訊息會說可能被系統對話框(通知權限、Safari 提示)擋住,先 sandbox_shot 看一下。
  • psbx-ios key HID碼 按鍵:40 Enter、42 Backspace、43 Tab、41 Escape。
  • psbx-ios button home 按實體按鍵:home、lock、side-button、siri、apple-pay。
  • psbx-ios size 印出螢幕的像素寬高與倍率(1206 2622 3.000000)。

要到某個畫面,深層連結(simctl openurl)或 UI 測試 target(xcodebuild test)還是比較快;用 sandbox_shot 或 psbx-ios screenshot 確認。要抓很短的變化(例如啟動畫面交接到第一個畫面),psbx-ios record 秒數 檔案 以模擬器原本的影格率錄 1 到 120 秒,存成箱子裡的 mp4(psbx-ios record 5 /work/launch.mp4,再用 ffmpeg -i /work/launch.mp4 -vf fps=20 /work/f_%03d.png 拆成單張);psbx-ios screenshot 一張約 0.7 秒,來不及。sandbox_shot 的 record 錄的是箱子畫面,也就是每秒約 10 格的即時影像。

要給人看原生 app,先自己點過、打過字(Android 用 artemis-adb tap/type,iOS 用 psbx-ios tap/type,再 sandbox_shot),讓它停在起始畫面、在裝置上開著,以 open: "box" 呼叫 sandbox_review。卡片打開箱子的畫面,人在上面自己點、打字試用原生 app。交件不會讓任何東西保持醒著:裝置照一般閒置規則跟箱子一起凍結、一起回收,人打開交件時跟著箱子開回來。


所有工具與主題都列在工具參考。