DEV Community

Duchan
Duchan

Posted on

We built a self-hosted Appetize alternative — no cloud uploads, no monthly fees, WDA-free iOS touch

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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) }
Enter fullscreen mode Exit fullscreen mode

What currently works

  • iOS simulator streaming + touch/swipe/pinch/software keyboard
  • Android emulator streaming + touch/swipe/pinch/hardware buttons
  • App Center — .app.zip / .apk uploads 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.
Enter fullscreen mode Exit fullscreen mode

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)