TL;DR
tapflow is an open-source, self-hosted tool that lets QA teams interact with iOS simulators and Android emulators directly from the browser.
It works similarly to Appetize, but everything runs on your own Macs and your app binaries never leave your infrastructure.
Getting started takes two commands:
npm install -g tapflow
tapflow start
Why we built it
Once our team started involving QA engineers, things became messy.
Developers could run simulators locally on their Macs, but QA either had to:
- sit next to a developer,
- borrow a Mac,
- or constantly ask someone to screen-share.
We tried Appetize. It worked well, but eventually two things started bothering us:
- the monthly bill (~$200+)
- uploading app binaries to external servers
BrowserStack was even more expensive.
At some point we started asking ourselves:
If simulators are already running on our Macs, why can't we just access them from the browser?
So we built tapflow.
Architecture
Browser (QA team) ←─ WebSocket ─→ Relay Server ←─ WebSocket (outbound) ─→ Mac Agent
(Linux / Mac) (iOS · Android)
There are three core components.
Mac Agent
Runs on a Mac.
It captures simulator frames and forwards input events (touch, swipe, keyboard, etc).
The agent establishes an outbound WebSocket connection to the relay server, so no firewall or NAT configuration is required.
Relay Server
Built with Node.js + SQLite.
It relays messages between browsers and agents, and also serves the dashboard SPA from the same port.
The relay can run on Linux or macOS.
Dashboard
A React 19 SPA.
This is the only thing QA teams interact with.
No Xcode. No Android Studio. No simulator setup.
Just a browser.
The technically interesting parts
iOS touch input — without WebDriverAgent
WebDriverAgent tends to break whenever Xcode updates.
It also requires provisioning profiles and only works properly while the app is running.
We decided to take a different route.
Instead of WDA, we load CoreSimulator.framework dynamically using dlopen, then directly call SimDeviceLegacyHIDClient and IndigoHID from a Swift binary (touch-helper).
That lets us inject HID events directly into the simulator.
// touch-helper — inject HID events directly into the simulator
let client = SimDeviceLegacyHIDClient(device: device)
let event = IndigoHIDEvent.touch(x: x, y: y, phase: .began) client.send(event)
This removes the dependency hell around WDA and works independently of the app lifecycle.
iOS streaming — SimulatorKit IOSurface
The screenshot APIs exposed through xcrun simctl had too much latency for real-time interaction.
Instead, we access IOSurface directly through SimulatorKit and pull frames straight from the GPU surface.
Frames are JPEG-encoded on the Mac and streamed over WebSocket, which gives us roughly ~30fps.
Android — scrcpy H.264 → WebGL
Android turned out to be much cleaner thanks to scrcpy.
We receive the H.264 Annex B stream from scrcpy over a local TCP socket, relay it through WebSocket, then decode and render it in the browser using WebGL2.
scrcpy server (emulator)
→ TCP socket
→ Agent
→ WebSocket
→ Browser
→ WebGL2
Android pinch gestures — scrcpy multi-touch protocol
scrcpy's INJECT_TOUCH_EVENT supports multiple pointer IDs.
Pinch gestures are implemented by sending two simultaneous touch events with different pointer IDs.
// ScrcpyControl — multi-touch injection
pinchStart(x1: number, y1: number, x2: number, y2: number): void { this.touchDown(0, x1, y1) this.touchDown(1, x2, y2) }
What currently works
- iOS simulator streaming + touch/swipe/pinch/software keyboard
- Android emulator streaming + touch/swipe/pinch/hardware buttons
- App Center —
.app.zip/.apkuploads with REST API + PAT support - Build workflow management: Backlog / In Progress / Done / Rejected
- QA session recording (72h retention)
- Team management — invitations and role-based access
- Mac resource monitoring — CPU / RAM time-series charts
Getting started
npm install -g tapflow
tapflow start
# → http://localhost:4000
tapflow init
For team deployments, the relay and agents can be separated.
# Relay server (Linux/macOS)
JWT_SECRET=$(openssl rand -hex 32) tapflow relay start
# Mac agents
tapflow agent start --relay wss://your-relay-url
The relay server itself can run on something as small as a ~$5/month Fly.io instance.
Honest limitations
- macOS is still required for iOS simulators (Apple limitation)
- One Mac typically handles ~2–4 simulators depending on RAM
- Still v0.x — breaking changes may happen before v1.0
- WebSocket backpressure handling is not implemented yet
We're currently exploring WebRTC as a future transport layer.
Contributions are welcome
tapflow is fully MIT licensed.
We're especially interested in contributions from people familiar with:
- iOS Private APIs / CoreSimulator
- scrcpy internals
- WebRTC infrastructure
🔗 GitHub: https://github.com/jo-duchan/tapflow
📖 Docs: https://www.tapflow.dev
Top comments (0)