All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
- The Mac Resources charts follow the clock instead of freezing where the page loaded (#751, #749). The history was fetched once, so an open page never showed anything newer than when you arrived, and the gap between the last time label and the right edge looked like a skewed axis. The window now advances with time and the history refreshes on a cadence matched to the range (1 minute on 1h/6h, 5 on 24h, 15 on 7d), pausing while the tab is hidden; a failed refresh keeps the chart as it was. Each line now ends in a dot at the Mac's latest report, so it reaches the present instead of stopping up to a minute short, and hovering the dot reads its current value. Time labels sit on local round times rather than UTC ones, which were not round in 45-minute zones and put a 7d label on the wrong date west of Greenwich.
0.21.0 - 2026-09-12
-
Docker is a documented way to run the relay. The image has been on Docker Hub since 0.20.0 and nothing in the guide said so, so the only way to find it was to guess the name. Self-Hosting the Relay now carries a Compose file and the three settings a container needs that a local install does not — each of which fails in a way that does not name its cause.
The image is the relay, not tapflow. It serves the dashboard and brokers traffic; the agents that drive simulators and emulators are macOS-native and stay on your Macs, connecting outbound to the container with an
agent-scope token. A container on its own streams nothing.Run it on the same Mac or a box on your own network — not on a cloud VM. Every video and audio frame between an agent and a browser passes through the relay, so a relay outside your network sends app screen data out with it, and the detour costs more latency than a 30fps budget can absorb.
The three settings: the data volume is required rather than a convenience, because the relay writes a per-install sign-in secret to
<dataDir>/jwt-secretand anything that recreates the container destroys an unmounted one — logging out every user and dropping every agent at once.TAPFLOW_RELAY_URLdecides what invite links say, and unset they point atlocalhost:4000, which is the recipient's own machine; the relay never reads its address from theHostheader, because a forged one would turn an invite into a phishing link.TAPFLOW_ADMIN_EMAILandTAPFLOW_ADMIN_PASSWORDcreate the first account, since the interactive setup a local install offers has no terminal to run in.The image also gained the toolchain its own fallback assumed:
better-sqlite3is fetched as a prebuilt binary and compiles from source when that fetch fails, except the builder had no compiler, so the fallback could never run and a failed download surfaced as a missing Python. Every published image is now booted in CI on both architectures before its manifest is pushed — the dashboard is fetched, the auth endpoint is asked for its state, a WebSocket is opened, and the container is destroyed and recreated to prove the sign-in secret survived on the volume.And installing a build into it works now, which it did not when that documentation was written — see the first entry under Fixed. A documented deployment that could stream a device but never install anything onto it would have been worse than no documentation.
-
Installing a build works when the relay is not on the same machine as the agent — it never did. The relay sent the agent its own filesystem path and the agent opened it, which holds only when the two share a disk. So on the topology the guide recommends — a relay on a LAN box, agents on Macs — every install failed, and a relay in a container failed the same way. It also failed by blaming the build:
unzipsaidcannot find or open, the agent discarded that, and the browser was told to check whether the file was a real simulator archive. It was, every time.The relay now mints a single-use credential where it has already established who owns the session, and serves that one build against it. No token gains a permission: an agent still cannot ask for a build it was not told to install, and
tapflow start— whose agent runs with no token at all — keeps working, which a permissions-based design would have broken. The agent forms the address from the relay URL it is already connected to, not from anything the relay says about itself, because that is the one address known to reach it.The three causes a person acts on differently — a damaged archive, a relay that cannot be reached, a transfer cut short — are now three different messages, and the extraction tool's own words ride along. A short transfer is caught against a size that travels with the instruction rather than against
Content-Length, which a proxy may drop; a check that reads an absent header passes while looking at nothing, and hands on half a file to be reported as a damaged one. Downloads time out instead of hanging, and the Android install path gained the temp-file cleanup it never had. An agent too old to fetch builds installs exactly as before, and the relay notes that once in its log rather than trying to decide whether that agent is elsewhere — it cannot tell, and the check would have been wrong in both directions. -
Pressing the upper half of Volume Up pressed the Action button on an iPhone. The tooltip said Action and the press followed it, so a tester reaching for volume changed a setting they never opened. A button's catchment was a fixed radius around its centre, and the first button in range won rather than the nearest — on an iPhone 15 Pro the Action button sits close above a much taller Volume Up, so Action's circle covered Volume Up's own pixels and claimed them, because that is the order the agent reports them in. Catchment is now measured to each button's rectangle and the nearest wins, so a press inside a button cannot lose to a neighbour. Targets are as generous as before — the same margin surrounds the button instead of radiating from its middle. Found by hand on a simulator; nothing automated was looking at where these targets actually are.
-
The dashboard is finally compressed on the deployment almost everyone runs (#260, #737). tapflow has been building Brotli copies of its dashboard assets for a while, and on a default install they were never sent. Browsers only offer Brotli on a secure origin, and the relay is plain HTTP unless you configure TLS — so over
http://<lan-box>:4000the browser asks for gzip, the relay had only.bron disk, and every asset went out uncompressed. The build now writes.gzalongside.br, and the relay serves whichever the browser will take, preferring Brotli when both are offered. Nothing is compressed at request time: both files are built once, so this costs no CPU while you are streaming. Content-hashed files under/assets/are also markedimmutablefor a year, so a returning tab stops re-fetching them, whileindex.htmlis markedno-cacheso an update is picked up on the next load rather than after a hard refresh. -
android-agent: Log unexpected scrcpy server process exits with their exit code and signal as warnings, while expected exits from
stop()log at debug level (#481). -
Android and iOS now show the same unsupported-streaming status. When the browser cannot decode the device stream, both viewers now report it through the shared status region instead of Android showing a Korean-only message and iOS showing nothing.
-
A state file anyone could have written no longer confirms the iOS network filter (#734). The filter publishes what it is enforcing to a file, and falls back to
/tmpwhen its protected directory refuses it./tmpis world-writable, so any local process could write a file there with a current timestamp and a rule naming a device, and since the state file became the confirmation of a rule write the agent would take that simulator offline on its word — the sign-off failure the feature exists to prevent. A state file in a world-writable directory is now believed only when root owns it and nobody else can change it, checked on the file that is then read rather than on the path. The liveness check reads the same way, so a forged file cannot hide a filter that stopped either. The protected path is unaffected, and a refused file is named once in the log. -
An app that uses Alamofire or Reachability.swift now shows its offline screen when you take an iOS simulator off the network. Taking a device offline already stopped its traffic, and an app built on
NWPathMonitor— the modern API — drew its offline state correctly. An app that asksSCNetworkReachabilityinstead was never told: it went on reporting a reachable network while every request failed, so the offline screen you came to check never appeared and the traffic being blocked looked like the feature not working. That API is answered now. What made it more than a one-line change is that these libraries do not poll — they register a callback, remember what it last told them, and recompute only when it fires — so tapflow re-fires that callback rather than only changing what a poll would return. Both ways a library can ask for that callback are covered, a dispatch queue and a run loop, and each is re-fired where its owner asked for it rather than wherever tapflow happened to be. -
Taking an iOS simulator off the network fails its requests in about half a second, instead of hanging for twenty-five. The filter blocked name resolution along with everything else, and a blocked DNS query returns nothing at all to whoever sent it — no error, no refusal — so the resolver simply waited out its own timeout. A request for a name the simulator had looked up recently failed in six milliseconds; one that needed a fresh lookup took 25 seconds in a command-line client and left Safari on a blank page past 35. That is the shape the complaint took: the toggle looked like it had not worked, because nothing on the screen changed for half a minute. Name resolution now passes through, so every request behaves like the fast case — the name resolves and the connection is dropped immediately. Measured after the change: the request now costs whatever the lookup costs — 0.3 to 0.6 seconds across runs, where it used to be 25. Safari's error page arrives in about 2. Only outbound UDP to port 53 is allowed — a connection to that same port over TCP stays blocked, because that already failed immediately and opening it would let a device you took offline hold a connection to anything listening there. Your app is affected too, though less than everything else was: tapflow refuses name resolution inside the app under test only where the app resolves the POSIX way, and
URLSessionresolves through a path tapflow cannot reach — so it now resolves the name and fails when it connects, where a device with no signal would have failed the lookup itself. An app that treats "the name resolved" as "I am online" will draw an online banner over a device that can reach nothing. What is unambiguously better is everything tapflow cannot reach at all, a web view or another app, which used to hang for half a minute. Encrypted DNS is not covered: DNS-over-TLS has a port of its own and could be added, DNS-over-HTTPS cannot be told apart from ordinary web traffic, and neither is included because nothing has yet measured whether a simulator uses them when the Mac is set up that way.
js-yamlmoved to 3.15.2 / 4.3.2 andhonoto 4.13.7, closing five Dependabot alerts across four advisories, andpnpm.overridesstayed empty. The two counts differ because GHSA-2883-xcg3-v3hh is raised once for each ofjs-yaml's affected version lines, 3.x and 4.x — the same shape as alerts #71 and #72 in the previous cycle. The four are GHSA-2883-xcg3-v3hh, GHSA-crvj-82cr-hjcx, GHSA-g6gw-c38x-mqfc and GHSA-gqvv-2mrq-wpjv. Dependabot reported both as impossible —security_update_not_possible,latest-resolvable-version: 3.15.1against a floor of 3.15.2, withconflicting-dependencies: []naming nothing that blocked it. Nothing did:read-yaml-filedeclares^3.6.1, which admits 3.15.2 freely, andpnpm updatetook all five with a fifteen-line lockfile change and no pin. That is the second time in two cycles that a bump reported as blocked was a lockfile which had never re-evaluated a range that already allowed the patch — see the entry under 0.20.0 below, which says the same thing about the previousjs-yamladvisory.js-yaml(GHSA-2883-xcg3-v3hh,maxTotalMergeKeysdoes not limit CPU use for empty merge sources) is a development dependency only — it reaches the tree through@changesets/cliand@manypkg/get-packages, and no published package's production tree contains it.hono(GHSA-crvj-82cr-hjcx, GHSA-g6gw-c38x-mqfc, GHSA-gqvv-2mrq-wpjv) does sit in a published package's production tree, through@modelcontextprotocol/sdkinside@tapflowio/mcp-server— and no hono code runs. The SDK reaches it fromserver/streamableHttp.js, and tapflow's MCP server importsserver/mcp.jsandserver/stdio.jsand speaks over stdio. Verified by loading each entry point under a resolver hook rather than by reading imports: those two pull in nothing matchinghono, whilestreamableHttp.jsas a control pulls@hono/node-serverand thenhono/ws. So this is hygiene, not a hole being closed. It is worth stating plainly because the first attempt to establish it was a source grep forfrom 'hono', which missed@hono/node-serverentirely and would have called a first-class SDK export an example.
0.20.1 - 2026-09-04
- tapflow no longer pins any transitive dependency. The
pnpm.overridesblock is empty. Nothing you install changes — every package it named already resolves at or above its security floor without it, checked by resolving the project both ways and comparing. It is mentioned because three of the eight entries had quietly gone out of date: each pinnedfast-uriup to a version that later advisories moved past, so had any of them taken effect it would have chosen a version that was still affected, while looking, to anyone reading the list, like the matter was handled.
-
A Docker install can create its first account (#352). Until now it could not, at all:
POST /api/v1/auth/initonly answers a local client — the check that stops a stranger claiming a public instance before you set a password — and a container is always behind its bridge gateway, sodocker compose upended at a login screen nobody could get past. The error text points attapflow admin init, but the image is relay-only by design and carries no CLI to run it. SetTAPFLOW_ADMIN_EMAILandTAPFLOW_ADMIN_PASSWORDand the relay creates that first Admin while it boots. They can live in<dataDir>/.envinstead of your compose file, which keeps the password out of your shell history and inside the volume you already mount — butchmod 600it yourself:tapflow initcreates that file 0600 and the relay-only image has no CLI, so a container operator writes it under their own umask. The relay warns at boot when it is readable by others. It does nothing when an owner already exists, so your account is never replaced and restarts do not repeat it. A password under 8 characters, or one variable without the other, stops the relay starting — that only happens on an install with no owner yet, where serving anyway would leave it claimable by anything that reaches loopback, and where there is no working service to lose because nobody can log in either. Nothing changes if you do not set them. Deploying tapflow with Docker is not documented yet — there is no Compose file in this repository and no deployment guide, so this removes the wall at the end of that path rather than opening the path. -
iOS network control keeps working after the filter is upgraded (#733). Every release that changes the filter replaces its system extension, and the version being retired holds on to the channel tapflow uses to ask the filter what it is enforcing. The new filter could not claim that channel, so the question went unanswered — while the filter itself was working normally. tapflow read the silence as "not working" and Take device offline went unavailable on Macs that had just upgraded, with
tapflow doctor iosshowing every check green. It now asks a second way, reading what the filter publishes about itself, so the control survives the upgrade that used to break it. -
Upgrading the filter is less likely to interrupt the Mac's network (#733). The entry below switches the filter off before activating a replacement, which is what stops the outage it describes — but copying the new app into
/Applicationsalso makes macOS restart the filter on its own schedule, and that step ran while the filter was still on. It happened to finish 69 milliseconds ahead of the restart, which is a race rather than a margin: a slower disk reorders them. The filter is now switched off before the copy as well. Two other things that made the window longer are gone with it: the filter republishes its state as soon as it changes rather than waiting out an idle timer, and a filter that fails to open its control channel now says so in the log instead of reporting success. -
Installing the network filter now checks that it came back (#725). macOS answers "not refused" rather than "working" when the filter is installed, and the entry below means the command has switched the filter off to replace it safely — so
tapflow migrate net-filtercould report that iOS network control was available over a Mac where nothing was filtering. It waits for a filter to report itself running — one that started after the install, rather than the previous filter's last heartbeat, which stays readable for a few seconds after it dies — and leaves as soon as one does.tapflow setup iosdoes the same. When none appears the command says so and exits non-zero rather than claiming success, and tells you how to take the filter out of the path if new connections on the Mac have stopped. -
A renewed signing profile or a new Xcode now counts as a changed filter (#728). Since the previous entry, the extension keeps its version when nothing about it changed — which means anything the check misses stops being an extra replace and becomes one macOS skips silently, leaving a Mac on the old filter with every version reading correctly. Two things change the shipped extension without any source file moving: the provisioning profile it is signed with, renewed once a year, and the Xcode that builds it. Both are compared now. The build machine's own OS version is deliberately not, because it changes with every macOS point update and would make a software update replace the filter on every Mac.
-
Upgrading tapflow no longer replaces the network filter when the filter did not change (#724). Replacing it interrupts every new connection on your Mac while it happens, and until now every tapflow release that touched the filter's tooling at all triggered one — three of the six rebuilds so far changed nothing the extension is built from. The extension now keeps its version unless its own sources changed, so those releases cost a file copy and nothing else. The first rebuild after this change still replaces it once, because the build script itself counts as an input.
tapflow doctor iosalso tells the two apart now: when only the app in/Applicationsis behind it says so and points attapflow migrate net-filter, rather than reporting a healthy filter over a binary the agent is about to call with flags it does not understand. And if the app has been deleted while its extension is still running, tapflow refuses to reinstall rather than guessing — it cannot tell whether what is on that Mac is newer than what it carries, and says which two things will fix it. -
Replacing the iOS network filter no longer takes your Mac's network down with it (#723). The filter sits in front of every new connection on the Mac, not only the simulator's — that is how one simulator can be taken offline while nothing else is.
tapflow migrate net-filterreplaced it while that was still switched on, and when a filter stops while it is switched on macOS does not let traffic through unchecked — it blocks all of it, which is the safe choice for a filter and a sudden one for you. New connections failed immediately withNo route to hostrather than hanging. Connections already open kept working, so what you saw was a dead browser next to things that carried on, and a restart looked like the only way out. It was not:TapflowNetFilter --offtakes the filter out of the path and your traffic returns, and Troubleshooting now says so. The command switches the filter off before it activates the replacement and back on afterwards, and if it fails partway it tells you the filter is off rather than leaving you to find out. It also refuses while devices are in use — booted simulators, attached emulators, or a relay serving on:4000, because the person testing through that relay is not necessarily you;--ignore-running-devicesreplaces it anyway. A filter that was switched off is no longer reported as up to date:tapflow doctor iossays it is switched off and names the command that turns it back on, and neithermigrate net-filternorsetup iosnow treats a stopped filter as nothing to do. So a Mac interrupted midway is repaired by running the command again instead of showing green checks over a control that does not work. -
The record button no longer drops keyboard focus when a recording stops (#624). It was
disabledwhile the recording was processed and once it was saved, which takes a focused button out of the tab order — so activating "Stop recording" from the keyboard left the user on the page body, and the name that changed to say what happened was announced to nobody. The button now stays focusable and refuses the click itself, and a live region beside it says when the recording is being processed and when it is saved. -
MacResourcesnow has a stable page-level heading, exposes the selected Mac viaaria-current, and announces each Mac's online/offline state through visually hidden status text; the comment composer's file-attachment errors are announced and tied to the attach button. -
TLS startup instructions now use the certificate-resolved hostname for remote agent connections (#627). HTTP and local fallback paths intentionally retain the host placeholder because an agent running on another Mac must not be directed to
localhost. -
The resource charts no longer reserve space for time that has not happened. The window's right edge was rounded up to the next round tick so the tick labels would stay on clean times, which left up to a full step of axis that no sample can ever reach — an hour of empty chart on the 6h range, and 63 pixels of 504 on 7d. Empty because it is in the future, which reads as a gap in the data rather than as the edge of the window. The window now ends at the moment of the reading and the ticks are counted down from the last round step at or before it, so the labels stay round and the newest sample sits at the right edge. The same strip existed at both ends for a second reason — the plot was padded 16px inside the gridlines that frame it — so the window's own edges are now the grid's edges and the chart is filled from one side to the other.
-
The device viewer no longer draws a box around itself while you type. Clicking the phone put the browser's focus on the whole viewer, and a focus has to be shown — so a ring appeared around everything at once, the phone and the buttons and the status card, and it came back mid-sentence on the keystrokes you were sending to the device. The viewer no longer takes focus at all. Nothing depended on it: tapflow starts forwarding your keys when you click the screen, not when the browser focuses something, so typing at the phone works exactly as before. The one place that focus was doing real work is kept — restart a device and you are put back on the restart button when it comes back, instead of at the top of the page.
0.20.0 - 2026-08-28
AudioStreamCapabilityandhasAudioCapabilityare gone from@tapflowio/agent-core. Nothing implemented them, nothing called them, and audio was never a detectable capability — it has noAgentCapabilitystring because nothing gates on it, and the agents stream it through their own per-session machinery. An interface declared for a detection nobody performs told a third-party platform to do something neither built-in agent does.Migrate:if you built a platform against it, drop theimplements AudioStreamCapabilityclause and its import — leaving the import behind is a hard error, not a warning. If you calledhasAudioCapability, drop the check: audio was never gated, so there is nothing to detect. The frame types are unchanged and still exported —AudioFormat,AudioFrame,AudioSampleFormatandAudioChannelsnow come from the package's shared types rather than from that file.
-
Restart a device without leaving the session (#628). The network control could tell you to restart the device, and there was no way to do it from the screen you were on — the same is true of the input errors that say a device is not booted. The only route was back to the device list to pick it again, which loses the session. There is now a restart button in the toolbar, last in the group that holds the device's own controls. It asks first, because whatever state you had built up on the device does not come back; installed apps and their data do, since this restarts rather than erases — wiping is still the toggle on the device list where a session is being started. If the device does not answer, tapflow says so rather than spinning, and an answer that arrives late still finishes the restart rather than leaving the device switched off.
-
The iOS network filter now comes with tapflow, and one command installs it (#647). Taking an iOS simulator off the network needs a small system extension on the Mac that runs the agent, and until now tapflow did not ship it — so the feature worked and nobody outside the project could use it. It now travels inside the agent package:
tapflow setup iosoffers to put it in place on a new machine — it asks first, since a system extension that sees every connection a simulator opens is the last thing that should install unasked — andtapflow migrate net-filterdoes the same for a Mac that is already configured, or one where setup was declined.tapflow doctor iostells you three things rather than one — whether it is installed, whether you have approved it in System Settings, and whether the extension actually running is the one your tapflow carries. That last one is not pedantry: replacing an extension only finishes when the Mac restarts, so the file can be up to date while the old one is still doing the filtering, and that is exactly when the control says your Mac is not set up. What you are trusting is written down — the extension is handed every connection the simulator opens before the traffic leaves the Mac, it is signed by the project, nothing leaves your Mac, and the docs say plainly what the signature does and does not prove. -
Take an iOS simulator off the network, and put it back (#607). The counterpart to the Android half above, and it takes three mechanisms rather than one, because a simulator has no radio to switch off — it is processes on your Mac sharing your Mac's network stack. So a host-level filter drops that one simulator's traffic, an injected library tells the app under test its path is unsatisfied, and the status bar stops showing service. Each alone would let you sign off on something untrue: block the traffic without telling the app and the offline banner never appears, tell the app without blocking anything and only the banner is real, change the status bar alone and nothing is. Only the simulator you toggled is affected — your Mac keeps working, and so does every other simulator, which is what tells two testers' sessions apart on one machine. The connections the app is already holding are cut too, so going offline mid-session behaves like losing signal rather than like a device that keeps talking over the socket it already had. localhost keeps working, so a dev build talking to Metro on your Mac, and tapflow's own instrumentation inside the simulator, are untouched. This needs a signed system extension on the Mac running the agent, which now ships with tapflow — see the entry above for installing it; until it is installed the control says so instead of failing, and the app under test has to have been launched through tapflow for the app-facing half to work — the control says that too, and says what to do about it.
-
Take an Android device off the network, and put it back (#607). Airplane mode, so the emulator is genuinely offline rather than the app being told a story — offline banners appear, retries fail, and cached screens behave the way they will on a real phone in a lift. A device someone left offline is put back on the network when the next session boots it, so you never inherit a colleague's test state.
-
Take a device off the network from the browser (#607). A button in the simulator toolbar puts an Android emulator into airplane mode and takes it back out — so the offline banner, the failed retry and the stale cached screen can be seen without touching a terminal. It appears only for an agent that says it can do this, which is why the iOS half above needed no dashboard change of its own. The button has four positions rather than two: a device nobody has heard from yet and one whose report never came are shown differently from each other and from both on and off, because "on the network" is not a safe thing to say about a device nobody has heard from. A device whose network tapflow can no longer change still shows where it is — that is a different thing from not knowing, and the button says which. Neither unsettled position is disabled: clicking is what asks the device. And the toggle moves when the device answers, never when you click, so what is on screen is where the device is rather than where you asked it to go.
-
Reconnecting no longer loses track of whether the device is offline (#614). Close the tab, lose Wi-Fi for a moment, or open the session on a second screen, and the relay asks the agent to re-read the device rather than guessing — so the control shows what the device is actually doing instead of whatever it was doing when you first opened it. The relay deliberately does not remember the answer, because airplane mode can be changed by anyone with
adband a terminal, and a remembered value would be confidently wrong. -
Full reset now works on Android. The toggle wipes the emulator's user data before booting it, the counterpart to erasing a simulator on iOS — so a tester can start from a first-launch state without touching Android Studio. Because the wipe can only be applied while the emulator starts, one that is already running is stopped and started again, which is what iOS does for the same reason; expect the extra boot. If it will not stop — or if tapflow cannot see well enough to tell — the boot fails and says so rather than quietly handing you a device that was never wiped.
-no-snapshotwas already passed on every boot and is not this: it skips the saved snapshot and keeps user data, so nothing was being wiped before (#447).
-
The network control stops asking you to launch an app you already launched (#629). Taking an iOS simulator off the network needs a small library loaded into the app under test. If macOS declined to load it — a mismatched architecture, a change in a new Xcode — nothing said so anywhere: the control asked you to launch an app through tapflow, which you had, and went on asking for the rest of the session. It now says that tapflow could not confirm the library loaded — which is what is actually known, since nothing was heard from it — instead of asking again for something you had already done. And
tapflow doctor iosgained a check that reads what your Xcode provides, so the version of this failure that a new Xcode causes is visible before you start rather than after you have signed something off. -
Taking one simulator off the network no longer costs the whole Mac a little on every connection (#685). The filter that drops a simulator's traffic was working out which process owned every new connection your Mac made — your browser, your mail, everything — including while no device was offline at all, which is most of the time. With nothing to block, that answer could not change anything. It now does no work until a device is actually offline. This is not a speed-up you will feel: measured at roughly five connections a second, it was about 0.2% of one core, and turning the filter off entirely changed nothing observable. It stops because it was work being done for nothing.
-
tapflow can now tell that a simulator's traffic was actually dropped, not just that the filter was told to drop it (#654). The filter's status file said which devices it had been asked to block. That is not the same as blocking them — a simulator whose connections cannot be traced back to it keeps talking while the file looks perfect, which is deliberate, because guessing wrong there would cut your own browser. The file now records how many of each device's connections were actually dropped. Nothing changes on screen: a count of zero means nothing at all, since a device sitting idle makes no connections to drop, so it is written to the log for whoever is diagnosing rather than shown as a state. Requires reinstalling the network filter —
tapflow migrate net-filter— and until you do, everything behaves as before. -
A tapflow agent no longer answers for a device you did not ask about (#617). On a Mac running two Android emulators, the entry points that take no session — the ones an embedded caller or a third-party platform would use — resolved the device by whichever the relay registered first. A screenshot could come back from the other emulator, and taking a device off the network could take somebody else's off instead, while they were testing on it. They now refuse and say how many they found, which is what the iOS agent has done since the feature shipped. Nothing changes for the browser: every control you touch names its session already.
-
The network button no longer describes a device that is restarting (#625). A restart keeps the session, and the control only reset when the session changed — so for the half-minute an emulator takes to come back it kept showing the position from before, while the device was being brought up with the opposite setting. It now waits for the device to say where it is, the way it does on a first boot.
-
The device toolbar's groups mean something, and the same button is in the same place on both platforms (#634). The buttons are ordered by what you are doing to the device — move around the app, leave the device in a state, take something out of the session, change the environment around it — and Android's order came from the agent while iOS's came from the browser, which meant a change in one package could rearrange the toolbar in another with nothing to notice.
-
Starting a second tapflow agent no longer puts the first one's devices back online (#658). The network filter is shared by the whole Mac, and each agent used to write its entire idea of it — so a second agent starting, which knows of no offline device, wiped the first one's work. Its tester was left watching an offline control over an app that could reach the network fine. Agents now change only the devices they name.
tapflow agent startalso stops rather than starting a second agent for a platform that already has one on that Mac, which is the setup that made this reachable; one agent has always managed every simulator on its machine. -
A healthy app no longer reports that its network state could not be confirmed (#653). The library tapflow injects into the app writes a small file saying whether its hooks took, and it wrote it in place — truncating the file the agent may be reading at that moment. The agent reads it whenever a viewer joins, whenever a device becomes ready and after every toggle, so a read landing inside the write happened on sessions where nothing was wrong, and the control said it could not confirm the state. The file is now written beside its target and renamed onto it, so a reader sees the whole old one or the whole new one.
-
tapflow doctor iosreports the injected library, and a missing one stops asking for a pointless reboot (#653). The library tapflow injects to tell an app it is offline had no check anywhere, while the filter that cuts the traffic has five. A damaged install was therefore silent in the worst way: macOS ignores an injection path that does not exist without a word, so the app ran unhooked and the control went on asking you to launch an app through tapflow — while the app you launched was running in front of you, for as long as the session lasted. Doctor now names the library, and the control says tapflow could not reach inside the app, which is what happened. -
A failure to change the status bar no longer fails the whole toggle, and a half-written check no longer reads as a broken app. Taking an iOS simulator off the network sets three things, and the last of them — the status bar's signal indicator — only reports. If it failed, the whole request failed and said so, on a device whose traffic really had just been cut: you were told the toggle did not work while the app under test was already offline. It is now let go, and the next toggle sets the bar again. Separately, the small file the injected library writes to say whether it took is written a byte at a time, so reading it at the wrong instant gets half a file — which tapflow reported as "this app cannot be told it is offline", a verdict that file cannot support. It now says it could not confirm and to try again, which is what actually resolves it — the file finishes writing a moment later. Telling you to restart the device would have cost the session's app state to fix nothing.
-
The network control now refuses rather than half-working, and tells you when a device came back online behind your back. Taking an iOS simulator off the network needs three things to happen together, and only one of them actually stops traffic. If that one cannot be applied — the host filter is not installed, not approved, or not running — tapflow used to apply the other two anyway: the app under test was told it had no connection and its open sockets were cut, while every request it made carried on succeeding. You could watch an offline banner appear, tick off "handles no connection", and have tested nothing. It now applies all three or none, and the button says which. And enforcement can stop after you have already checked something: if the filter is switched off or crashes while a device is offline, the traffic flows again for a few seconds before macOS brings it back, so you are now interrupted and told that what you checked while it was off needs checking again — rather than being left looking at a control that still says offline.
-
The network button says what to do, and stops offering a retry that cannot work. Every failure used to arrive under one name, so a device that was merely rebooting was reported as one that will never do this — and the dashboard, unable to trust the name, said only "tapflow can no longer change it" for all of them. Each state now carries its own next step: launch an app through tapflow, restart the device, try again, or go and ask whoever runs the Mac. Where clicking again genuinely might work the button offers it; where it cannot, the button says so in its name instead of leaving the colour to carry it — which is the channel a screen reader does not have.
-
Updating the iOS network filter no longer stops without saying why, and its status file comes back after the filter is switched off and on. Two separate faults in the system extension that takes a simulator off the network, both of which made tapflow describe itself wrongly. Installing an update to the filter would hang and then report a timeout with nothing to explain it — macOS asks the app which version to keep, and the object meant to answer had already been released, so no answer ever came. That failure was investigated three times and written off as having no known cause; it never affected a first install, only a replacement. Separately, the small status file the filter writes — the thing that tells tapflow it is genuinely running — was removed when the filter stopped and then never rewritten, so from the first stop onward the file stayed missing while the filter went on filtering. Nothing read that file until the agent side landed in this same release, so this was never something anyone could have seen. It would have become visible as the opposite of the confusion the file was added to prevent: a device reported as beyond tapflow's control while its traffic really was being dropped.
-
tapflow can now tell when the network filter is not actually running. Taking an iOS simulator off the network needs a system extension on your Mac, and until now nothing checked that it was still there and doing its job — so if it had been disabled, or had crashed, the control would happily report the device as offline while traffic kept flowing. The filter now leaves a small status file that says what it is enforcing, refreshed every few seconds, and removed when it stops. Also: changing a device's network no longer asks macOS to re-install the extension every single time, which it was doing for what is really just a settings write.
-
The offline banner no longer risks crashing the app you are testing. When you take a simulator off the network, tapflow re-delivers the app's own network-path handler so the app finds out. That handler was being called on tapflow's thread rather than the one the app asked for — and an app that updates its UI from it was then doing so off the main thread. It now runs where its owner said it should. Cutting the app's open connections got safer too: a descriptor that changes identity while tapflow is deciding about it — or that cannot be identified at all — is now left alone, rather than cut on a verdict about a socket that is already gone.
-
Reconnecting no longer makes tapflow forget which simulator is yours. When the relay restarts — an upgrade, a dropped Wi-Fi moment, a laptop waking — the agent reconnects and used to lose track of which devices were running. Anything that had to work out which device you meant then failed on one that was up in front of you: taking a screenshot, launching an app, reading the UI tree, opening a URL, installing a build. It stayed wrong until you happened to tap the screen or boot something, which on an idle session could be never. Those now ask the simulator instead of trusting a note they had just thrown away — and if you also have a simulator of your own open in Simulator.app, tapflow still knows which one is its, rather than refusing because it can see two.
-
The network button no longer looks broken while it works. Two problems, both about a control saying the opposite of what it does. Pointing at it repainted it as an ordinary button, so whatever colour it was using to report a state disappeared exactly while you were looking at it. And on iOS, the ordinary opening seconds of a session — the injection in place, no app launched under it yet — were reported as a failure, drawn as if nothing could be done and captioned "tapflow can no longer change it": wrong about the past, since nothing had been armed, and wrong about the present, since clicking really does take the device off the network. That state now says what is actually missing — launch an app so it is told too — while a control tapflow really cannot steer is drawn as unusable whichever way the device is pointing, rather than only when it is online — it used to go faint at offline, which reads as a button you cannot press.
-
Relay startup output now advertises the first concrete non-
localhostDNS SAN from an imported TLS certificate, falling back to a concrete subject CN only when the SAN extension is absent. Certificates with unusable DNS SANs keep thelocalhostfallback and emit a warning; IP-only and malformed certificates fall back without that warning. (#293) -
Full reset now appears based on what the device agent says it can do, rather than on which platform you picked. The control was offered for every iOS device and hidden for every Android one, which was right about the agents of the day and wrong about any other combination: an agent older than the feature was still offered a toggle it has no code for, and an Android agent that gains the ability would still have had it hidden — which is the half that landed above in this same release. If you run an agent from before this release against a newer relay, the toggle is now correctly absent instead of erasing nothing — one more reason to upgrade agents and relay together, as 0.19.0 asked.
- The dashboard's icon set moved to lucide-react 1.x from 0.577 — housekeeping ahead of the network control for #607, which will use an icon lucide added in v1.6.0. Nothing you interact with changes: forty-nine of the fifty icons in use are drawn from identical data, the fiftieth is the book on the sidebar's Docs link and it has been redrawn with rounder corners, and all the JS the dashboard ships grows by 30 bytes.
js-yamlmoved to 3.15.1 / 4.3.1 (GHSA-5p4m-2wfm-xmqj — quadratic CPU consumption resolving!!omap), and its twopnpm.overridesentries were retired with it. The pin listed under 0.16.0 below no longer exists: the declared ranges had admitted the patch all along, so what held the old version in place was a lockfile that never re-evaluated them, not a range that forbade it. Development dependency only — no published package carriesjs-yaml.shell-quoteanddompurifylost theirpnpm.overridesentries as well, this time with no version change at all: both already sit at the highest floor any advisory for them asks for (1.9.0 and 3.4.13), and neither entry was doing the work.concurrentlypinsshell-quoteto an exact version, which the key<1.8.4never intersected;mermaid's caret ondompurifyreaches the same release the override named. 0.9.2 below announced a dompurify pin at 3.4.11, raised since without a note of its own — there is now no pin on either package at all. Both are development dependencies of private packages only.@hono/node-serverlost its entry as well, again with no version change, and this is the one that reaches a published package's production tree — through@modelcontextprotocol/sdkinside@tapflowio/mcp-server. It is also one of the two that pnpm was really consulting: the SDK declares^1.19.9 || ^2.0.5, the key<2.0.5intersected its 1.x branch, and the replacement>=2.0.5 <3therefore withdrew a major line the SDK says it supports. Installing@tapflowio/mcp-serverfrom npm was unaffected either way —pnpm.overridesdoes not travel into a published tarball, so a consumer has always resolved the SDK's own range.esbuildlost the last entry, emptying the block of everything the audit had flagged. It was the other one pnpm consulted —tsxdeclares~0.28.0and the key>=0.27.3 <0.28.1intersects the bottom of that — but the newest 0.28.x is 0.28.2, so the declared range reaches it unaided. 0.9.0 below announced an esbuild bump to clear advisories; that pin is gone too. This does not make esbuild clean.vite@5.4.21under vitepress resolves 0.21.5 andvite@6.4.3resolves 0.25.12, both inside GHSA-gv7w-rqvm-qjhr, and the retired key never intersected either caret — so nothing about them changed here. They are build tooling, they reach no published package, and moving them means moving vitepress.
0.19.0 - 2026-08-19
The wire answers. Every request now carries an identifier its reply echoes, every refusal comes back with a machine-readable reason, and a session belongs to the client that opened it rather than to one of its sockets. Most of what is listed below is one program: paths where nothing was sent, so whoever asked found out by waiting.
- Requires Node.js ≥ 22. Node 20 reached end of life on 2026-04-30 and no longer receives security
patches.
Migrate:upgrade the Node on the Mac running the agent and on whatever runs the relay.nvm install 22or the installer from nodejs.org;tapflow doctorreports the version it finds. - Update your agents and your relay together. Requests now carry an identifier the reply echoes, so
the relay can tell which answer belongs to which request. An agent from before this release does not
echo it, and the reply is then discarded rather than misattributed — safer, and still a failure: an app
install started from the dashboard sits on "Installing…" with no Launch control, and a deep link opened
through
open_urldoes nothing. A client newer than its relay has the mirror problem on a refused session join, which runs to its deadline instead of saying why.Migrate:upgrade every device agent to this release at the same time as the relay. Packages are versioned together, but nothing installs them together — this is the case where a Mac left on the previous agent is the one that breaks. - An agent must now send a machine-readable
reasonwhen it refuses an input.input:errorused to guarantee onlymessage, the human-readable prose each agent writes for itself, whilereason— the closed set a caller actually branches on — was optional. That is backwards: the field you were guaranteed was the one you must not depend on.reasonis required now andmessageis optional. Every agent shipped with tapflow already sends one, so no producer here had to change; what this affects is a third-party or self-modified agent built against an older contract.Migrate:addreasonto everyinput:erroryour agent sends, choosing fromnot-booted,channel-starting,channel-unavailable,no-gesture,dispatch-failed,unsupportedormalformed. Pick by what the caller should do differently, not by which of your internal states produced it — the set is deliberately smaller than any one agent's internals. (not-session-owneris the eighth member and is the relay's alone: it refuses such a frame at its door, before any agent sees it.) Prose stays welcome inmessageand may now be omitted. @tapflowio/relayno longer exportsRelayMessageorMessageType. They were the relay's own copy of the wire contract — a flat interface wheretypewas the only required member, and a hand-maintained list of 63 literals beside it — and they disagreed with@tapflowio/protocolabout the same fields, which is the drift this release closes. Nothing in tapflow imported them; this affects code outside it that did.Migrate:import the message types from@tapflowio/protocolinstead, which declares one interface per message and unions them by direction —BrowserToRelay,AgentToRelay,BrowserInboundand so on. ARelayMessageused as "any frame on this socket" becomes the union for that socket's direction, and narrowing ontypegives the individual message.
- The relay now checks every message it receives against the contract, and refuses the ones that break it. Every frame is checked for its type, its address and its correlator; a command sent by a browser is checked in full, down to its payload. Until now it checked only what it sent. A command with a missing payload, an empty session id, or a build id that was not a number was forwarded to a device anyway — or answered with a reply whose own required field was missing, which every client discards, turning a diagnosis into a caller waiting out its deadline. A refused command is now answered where the request has a reply, so the caller is told which field was wrong instead of waiting; where it has none, the frame is dropped and the log names the field. Well-formed messages are unaffected.
- A field appended to a browser message no longer reaches a device. Anything the contract does not declare is removed before the relay forwards it on. Messages coming from an agent are forwarded untouched, so an agent newer than its relay does not lose fields it adds.
@tapflowio/protocolhas a second entry point,@tapflowio/protocol/validate. It holds the relay's inbound parser, and it brings the package its first runtime dependency (zod). The main entry is unchanged — types only, fully erased byimport type, and it does not reachzod— so a consumer that imports only@tapflowio/protocolgains nothing in its bundle.- Split stable dashboard vendor dependencies into smaller chunks to reduce maximum bundle size and improve cache reuse across releases.
- A refused session now says which session it refused and why. Opening a device someone else already has open, or one whose Mac is under load, used to produce a generic failure the dashboard could not attribute — with two tabs opening at once it could even be shown against the wrong one. The refusal now names the session and carries one of three reasons, so the second tester is told the device is in use rather than that something went wrong.
-
A boot that will not finish now says so, instead of letting you wait. Re-pick a device while the first one is still starting, or shut it down mid-boot, and the agent abandoned the earlier boot in silence — nothing was sent in either direction, so whoever asked found out by waiting: 30 seconds for an MCP caller, two minutes for a flow run, forever for a spinner. An abandoned boot is now answered on its own request — superseded by a newer boot, abandoned by a shutdown, or invalidated by the agent losing the relay — as long as the agent still has an open connection to answer on; when it does not, the relay ends the wait instead by declaring the agent away. A tester sees nothing for the boot they replaced themselves, and the failure of the one they are waiting on exactly as before.
-
A slow cold boot is no longer reported as a failure that never happened. The agents poll a booting device for up to 90 seconds (iOS) or 120 (Android) before explaining what went wrong;
mcp-servergave up at 30, inside both, so a device that was simply slow came back as a bare timeout while the explanation was on its way.flow-runnersat at exactly Android's 120, leaving no room at all. Both now wait past the agent. The cost of waiting longer is worth knowing: a wedged relay blocks an MCP caller for three minutes rather than 30 seconds, so a host whose own tool timeout is shorter will cut in first with a message of its own. -
Losing the relay mid-boot no longer leaves an Android agent finishing a boot nobody owns. Both agents drop their device state when the connection goes, but a boot already running holds its own reference to one; on Android it ran to completion against that, standing up a video stream and announcing the device ready for a session that no longer existed.
-
Nobody else can power off a device you are using. Any signed-in client that knew a session id could shut down a colleague's simulator mid-test. The check that stops it could not be added before: the browser tab that holds a session and the one that sends the shutdown when you navigate away are different connections, so refusing "not the holder" would have refused the tab's own cleanup and left devices running. A session now belongs to whoever opened it rather than to one of their connections.
-
A Wi-Fi blip no longer costs you your session. The relay treated a connection as present until TCP or a heartbeat noticed otherwise — up to a minute after a laptop went to sleep — so returning inside that window meant being told the device was in use, by yourself. A device whose tester's connection died — a sleeping laptop, a dropped network — frees up in at most 45 seconds rather than up to a minute, and no longer shows as free while it is still in use: "can I take this?" and the "In use" badge read the same signal now.
-
A refused
connect_devicesays which of the three refusals it was. It reported the relay's prose alone, so "the device is open in another browser session" and "this Mac is over its resource ceiling" arrived as sentences a model had to guess at rather than the closed reason it can act on. A failed screenshot or UI-tree query likewise says what is wrong with the session now, not only what the relay said about the request — so one failing because the agent dropped its device binding no longer reads as a bare status code. That is the least useful moment for it, since a screenshot is usually being taken to explain a step that already failed. -
Disconnecting from a session no longer leaves a request hanging for its full deadline. An AI agent that disconnects while a boot is still in flight — ordinary, since tool calls run in parallel — used to get a bare timeout thirty seconds later. It fails immediately now and says the disconnect is what ended it, while still warning that the request may have reached the device anyway. A worse version of the same gap could have reported a boot that never happened as success, after re-joining the same session.
-
A flow run whose device dies stops blaming the selector. When the agent restarts mid-run its device binding is gone, and nothing in a flow can restore it — flows boot once, before the first step. Every remaining step used to poll for its full timeout and fail with "no element matched", so a restart three steps into a ten-step flow spent eighty seconds pointing at the wrong thing. They now fail as soon as the query does and say the session needs booting again. The relay's fifteen-second grace for an agent that may come back is untouched: queries keep retrying through it exactly as before.
-
Shutting a device down through an MCP client fails in a second instead of half a minute.
device:shutdownwas the one command from a browser that the relay never answered when it could not deliver it — a stale session id or an agent that had gone away produced no reply at all, soshutdown_devicereported "Request timed out" with no cause after 30 seconds. It now says which of the two happened. The device list had the other half of the same silence: a row stuck on "Shutting down…" with both its buttons hidden for the rest of the page's life. -
A tester whose browser reconnects lands back in their session instead of being thrown out of it. Re-joining a session the tab already held was refused as "session not found" — for a live session, held by that tab, which the device list reported as theirs. The viewer reads that as the agent having disconnected, so a two-second Wi-Fi blip ended a session that was fine. Re-joining is now the same as joining, cached screen state included.
-
Devices no longer stay booted with nobody watching them. The relay tracked one session per browser connection, but one connection can hold several — an AI agent driving two devices uses a single one. When it went away only the most recent session was released; the rest stayed marked in use for as long as the relay ran, with no idle timeout, so their simulators kept running. Every session a connection held is released now, each with its own timeout.
-
An input that never reached the device was reported as having landed. Every path that could refuse or drop an input — a simulator that is not booted, an input channel still starting, an agent that went away, a helper process that died — either said nothing or said success. An LLM driving the device through MCP moved on as though the tap had happened; a
tapflow flowrun failed several steps later with "selector not found", which is the worst place to lose a cause; and the dashboard showed nothing at all. Every one of those now answers, and says which of the two it is: the input was refused, with the reason, or it could not be confirmed — which is not the same as saying it did not happen, because an acknowledgement can arrive after the wait for it has ended. Repeating an input that did land would duplicate it, so the message says to check the device rather than to retry. -
A reply could be attributed to the wrong request. Boots, shutdowns, app installs and launches, URL opens and input acknowledgements carried nothing tying a reply to the request that asked. Two overlapping requests on one session and the first answer settled the wrong one — so a boot that failed could be reported as the one that succeeded, and an acknowledgement that arrived late was read as the next input's. Each of those now carries a correlator the reply echoes.
-
A dead session hung until the deadline instead of saying so. When an agent went away mid-command, the relay said so on the wire, and the MCP server and flow runner ignored it — an app install waited out its full two minutes and reported a timeout. They read those messages now and fail with the reason. A device whose agent reconnected is reported as needing a boot rather than as a reset device, because the app is still running.
-
An app install could fail with "No devices are booted" on a device that was starting up. Booting a simulator was announced as finished when the command to boot it returned, which is 7.6 seconds before the device is actually ready. Anything issued in that window — an install, a launch, an input — hit a device that was still coming up. The agent now waits for the device to report itself booted before saying so.
-
A JPEG screenshot could come back as PNG bytes labelled JPEG. Android always produces PNG whatever is asked for, and the label was taken from the request. The MCP server picks its image parser by that label, so it measured PNG bytes with a JPEG parser and handed the model a wrong screen size — which the model then used as the divisor for every tap coordinate. The format is read from the bytes now.
-
tapflow flowreported an environment failure as a product failure. A step whose input the relay or the agent refused failed with the selector error from the next step rather than the refusal, so a CI run showed a broken assertion where the device had simply not been reachable. -
The iOS fallback video path emitted PNG frames under a JPEG label. No entrypoint shipped with tapflow selects it; it affects a consumer of
@tapflowio/ios-agentthat setsintervalMsitself. -
A scrcpy server process error could take down every Android device the agent manages, not just the one session. The scrcpy server process spawned for a real-device session had no error handler; an unhandled error on it (e.g. the server process failing to spawn, or a permission error on kill) crashed the whole android-agent process, ending every session it was managing. It's now logged instead.
-
TouchHelperandKeyboardHelperDaemoncould leave a wedged helper process running afterstop()— onlySIGTERMwas sent, with no fallback. Both now escalate toSIGKILLafter 1s if the process hasn't exited, matchingScreenCaptureStreamerandXCUITreeReader.
0.18.0 - 2026-08-03
- Restarting a device agent no longer costs you your place. Upgrading or restarting an agent used to end every session it held: the tab you had open was sent back to the Mac list, and you navigated back through the app to where you had been. The relay now holds a session for 15 seconds after its agent's connection drops, and an agent that comes back reclaims it — the stream returns on its own with the app still on the screen you left it on, and no reinstall, so nothing you had entered is lost. If the agent does not come back the tab says it is waiting and then says the session ended, instead of showing a picture that quietly stopped updating.
TAPFLOW_AGENT_GRACE_MSsets the window;0turns it off. @tapflowio/protocol, one place the WebSocket message shapes are declared for the relay, the dashboard and the MCP server. They were three hand-kept copies that had already drifted; a message the relay sends is now a compile error everywhere it is spelled wrong.
- Full reset erased devices nobody asked to erase, and failed on the ones people did. The toggle stayed on for the rest of the session, so every later reconnect wiped the simulator again — a Wi-Fi blip was enough. It also failed outright on a device that was already running, which is most of them. It now erases once, when asked, and shuts a running device down first.
- App install and launch failures reached nobody. A missing build, an unknown session or an offline agent produced either an error with no session attached or, when the agent was gone, nothing at all — so an MCP caller waited out its timeout and reported a failure with no cause. Every one of those paths now answers immediately, addressed to the session that asked.
- Commands went to whichever simulator happened to be booted. With two devices up, an install or a launch could land on the wrong one, and
simctlpicked silently rather than erroring. Each command now names the session's own device. - The dashboard could report a device as ready with nothing streaming behind it — a simulator someone had left running was announced as live before the agent had done anything with it, leaving a viewer waiting for a first frame that was never coming.
- Booting an iOS device no longer hides
Simulator.app. The workaround dated from an older Xcode and, on the supported line, only cost a window flash on every boot.
- An agent that goes away while you are watching now tells the tab so, and the tab says which of the two it is: waiting for the agent to come back, or the session is over. Both are new messages on the relay-to-browser contract; a viewer that predates them ignores them, as before.
- While an agent is away its devices are not offered on the Mac list, and
tapflow status,list_devicesover MCP and flow-runner see the same — a device nobody can reach for the length of the window is not one to hand out.
0.17.0 - 2026-07-27
- Copy and paste now cross between the dashboard and the device. Cmd/Ctrl+V sends your clipboard to the simulator or emulator and pastes it there; Cmd/Ctrl+C brings what you copied on the device to your own clipboard in one press. Previously neither direction existed, so accounts, tokens and deep links had to be retyped by hand. Paste works everywhere including plain-HTTP LAN; copy needs the dashboard over HTTPS or localhost, because proving the copy actually landed takes a round trip and no clipboard API available on plain HTTP accepts a value arriving that late — on plain HTTP the copy still reaches the device and the dashboard says why it stopped there. The agent presses the device-side chord itself and confirms the clipboard changed before answering, so a slow device cannot hand back its previous contents as if freshly copied.
- MCP
tap,swipe,press_keyandpress_buttonreport what actually happened instead of always reporting success. They were fire-and-forget: against a session whose device was not booted the input was dropped and still answered{tapped: true}, which also made parallel test results untrustworthy.
- Cmd/Ctrl+C, +V and +X on Android typed the letter into the app instead of copying, pasting or cutting. The key handler ignored the Ctrl/Meta modifier; a chord is now a command, not text.
- No audio from Android in the dashboard, with the emulator's sound coming out of the agent Mac's speakers instead. The bundled encoder binary lost its executable bit on a fresh install, so the agent fell back from the emulator's gRPC backend to scrcpy — and audio capture and host-mute only exist on the gRPC path. Only visible with the dashboard and the agent on separate Macs.
- iOS sessions silently dropped every tap, swipe and keystroke after an agent reconnect. The input channel was created only during
device:boot, so a session that came back without one discarded input with no error — and the device looked responsive because screenshots and UI-tree reads travel a different path.
- Agents advertise what they implement in
agent:register, and the relay echoes it to the viewer onsession:joined. Absent means unsupported, so an agent older than a capability keeps working untouched rather than being probed by timeout.
0.16.0 - 2026-07-22
- Flow selectors gain two optional disambiguators for the object form:
rolenarrows by element kind (e.g.{ label: "New Orders", role: button }when a button and its inner text share a label), andindex(0-based) picks the Nth remaining match (e.g.{ role: cell, index: 2 }for a label-less, id-less row). Additive — bare-string and{ id }/{ label }selectors are unchanged; the object form now needs at least one ofid/label/role. - MCP
run_flowinstalls the build before replaying whenbuildIdis set (parity withtapflow flow run --build), so a flow'sclearState/launchAppfinds the app present even after a session ended or the app was never installed. Passinstall: falseto skip. - MCP
shutdown_device— powers a session's booted simulator/emulator down to free resources or force a cold boot next time. Distinct fromdisconnect_device, which only leaves the session and keeps the device running.
tapflow flow run: wait steps (tapOn/assertVisible/assertNotVisible) no longer fail the instant a ui-tree query throws — e.g. the app not being in the foreground yet right afterlaunchApp. The poll loop retries transient query failures (foreground race, idle timeout, network) until the step deadline while failing fast on permanent ones (bad request, auth, missing session), and bounds each query with an abort signal so a stalled response can't block past the deadline. This removes the long-press-as-sleep workaround.
- Pinned transitive dependencies past their advisories via
pnpm.overrides:axios≥ 1.18.0 (GHSA-xj6q-8x83-jv6g),protobufjs≥ 7.6.5 (GHSA-j3f2-48v5-ccww),body-parser≥ 2.3.0 (GHSA-v422-hmwv-36x6), andjs-yaml4.x ≥ 4.3.0 (GHSA-52cp-r559-cp3m).
0.15.0 - 2026-07-20
tapflow migrate data-dir— a one-shot command that moves a legacy.tapflow-data/into the unified.tapflow/data/layout: atomic rename (no copy, no data loss), repointslocal.dataDirintapflow.config.jsonwhen it pinned the old default, and updates.gitignore. Idempotent; conflicting or cross-filesystem states stop with manual guidance.tapflow setup androidinstalls Androidbuild-tools(pinned35.0.0), andtapflow doctorgains anaapt (build-tools)check — apk metadata extraction needs it.
POST /api/v1/builds: an.apkupload that specifiesapp_idis now rejected with400whenever the relay can't read the APK's package name (Android build-tools /aaptmissing, or the APK itself unreadable/corrupt), instead of storing an unversioned build under that app. Migrate: install build-tools on the relay host withtapflow setup android(or re-export a valid APK), or omitapp_idto file the build separately.- The default relay data directory moved from
.tapflow-data/to.tapflow/data/, unifying all project state under a single.tapflow/root (data/runtime,flows/committed,artifacts/screenshots). Existing installs keep working without action: atapflow.config.jsonthat pinslocal.dataDir(which oldertapflow initwrote) is honored as-is, and a config-less default install keeps reading a pre-existing.tapflow-data/(with a one-line hint). To unify the layout, runtapflow migrate data-dironce — it atomically renames.tapflow-data/→.tapflow/data/(no copy, no data loss), repointslocal.dataDirwhen it pinned the old default, and adds the runtime paths to.gitignore. Cross-filesystem or conflicting states are reported with manual steps instead of guessing. Docker: the image volume moved from/app/.tapflow-datato/app/.tapflow/data— remount your data volume at the new path.
tapflow flow runnow writes failure screenshots to.tapflow/artifacts/by default (was.tapflow-data/artifacts/), matching the--artifactshelp text.
- relay: an
.apkwhose metadata can't be read is no longer merged into an unrelated app or false-promoted to platformboth; withoutapp_idit is isolated under its own entry.tapflow doctorand the relay now share the sameaaptsearch paths (ANDROID_SDK_ROOTand the Linux SDK path included), so a green doctor no longer masks an upload failure.
0.14.0 - 2026-07-09
- Automated QA axis.
query_ui_tree(MCP) andGET /api/v1/sessions/:sessionId/ui-treereturn a unified element schema (role/label/identifier/frame/enabled) with frames normalized 0–1, so a frame center feeds straight intotap. iOS reads the tree via a resident XCUITest runner inside the simulator — window-agnostic (no Simulator.app window, no WebDriverAgent); Android viauiautomator dumpwith a device-side timeout (#133). @tapflowio/flow-runner(new package) andtapflow flow runreplay YAML flows with zero LLM calls: a 10-step vocabulary, identifier/label selector resolution, condition-based waits, JUnit reports, failure screenshots, and a CI exit-code contract (0 pass / 1 flow failed / 2 env error).run_flow(MCP) — an agent authors a flow once, then replays it deterministically over the existing session.- relay
app:clear-state— reset app data (Androidpm clear, iOS data-container wipe). @tapflowio/mcp-serverand@tapflowio/flow-runnergraduate from theexperimentaldist-tag to the standard npm channel, versioned with the repo-wide fixed group.
- Text entry waits for an
input:type-doneack so a following key press stays correctly ordered. A self-hosted agent older than v0.14.0 does not send this ack — update the agent and relay together, or text steps will time out.
- mcp:
type_text, cross-platform hardware buttons, and input payloads aligned with the agent protocol (#376, #377).
0.13.0 - 2026-07-05
- relay: outbound webhooks for build review-status changes. The relay POSTs to registered URLs when a build's review status transitions to
DoneorRejected, so review outcomes can flow into Slack or the next CI step. Register at runtime viaPOST /api/v1/webhooks(builds:writescope) or declare endpoints intapflow.config.json(webhooks, with signing secrets read from env vars). Deliveries carry metadata only — never app binaries — and are HMAC-SHA256 signed (X-Tapflow-Signature) when a secret is set. Registration blocks loopback and cloud-metadata addresses (#367).
0.12.0 - 2026-07-03
- relay: accept EAS
eas buildiOS simulator artifacts (.tar.gz/.tgz) as a first-class build upload, alongside.app.zip(iOS) and.apk(Android). The archive is stored as-is and extracted withtarat install time — no re-zip — so the.app's executable bits and symlinks are preserved. Uploads are validated before storage: path traversal (../absolute), symbolic/hard links, corrupt gzip, and gzip bombs (TAPFLOW_MAX_UNPACKED_BYTES, default upload cap ×4) are rejected. Expo/EAS teams can now runeas build → CI → tapflowand upload the native.tar.gzdirectly, with no CI re-packaging step (#362).
0.11.1 - 2026-07-02
- relay: Docker support and a container image publish workflow, so the relay can be self-hosted as an image instead of only from source (#352).
- docs: add navigation links to the project changelog.
- deps: bump the npm minor/patch dependency group (22 updates).
- ios: physical device-frame buttons are confined to the bezel — a tap inside the screen area is no longer hijacked as a button press on devices where a button sits near the edge (e.g. iPhone SE). HID buttons also support press-and-hold via an optional
phase: 'down' | 'up'oninput:button; existing single-press clients are unaffected. - dashboard: use the
TimerOfficon for the cancel-deletion action.
- Patch js-yaml to 3.15.0 to address CVE-2026-53550.
0.11.0 - 2026-06-29
- audio: simulator/emulator audio output is streamed to the browser, on by default on both iOS and Android (opt out with
TAPFLOW_AUDIO=off). iOS taps the whole simulator process tree via Core Audio process taps (macOS 14.2+) — app audio, WebKitWebContent, and system sounds; Android captures over the emulator's gRPC stream. The agent Mac stays muted so audio goes only to the browser — on Android via a shared mute-only process tap (@tapflowio/audiotap-helper, macOS 14.2+; below that, use the Mac's volume). The simulator/emulator's own volume is reflected. (#339, #341) - docs: add self-hosted relay backup guidance for
.tapflow-data/, Litestream replication, restore order, and non-database artifacts.
- build: migrate the monorepo to TypeScript project references and point each package's
exports.typesat the publisheddist/*.d.ts(wassrc/, which isn't in the npm tarball) so consumers resolve types correctly. typecheck/build run viatsc -b. Also extracts the shared macOS process-tap helper into@tapflowio/audiotap-helper. (#345)
- android: concurrent emulators now each use their own gRPC port (discovered from the running emulator's
.ini) instead of a fixed8554, which collided and made every session show the first emulator's screen. - cli:
tapflow setup androidnow treats a missing emulator binary or Android system image as a partial SDK and repairs it instead of reporting the SDK as ready. - cli:
tapflow doctornow checks whether the default relay port 4000 is already in use and prints thelsof -ti:4000 | xargs killrecovery command beforetapflow starthitsEADDRINUSE. - cli:
tapflow setup androidnow reminds users to open a new shell when the Android SDK rc block already exists butadbis still missing from the livePATH;tapflow doctornow points to the shell-refresh step instead of looping back to setup.
0.10.0 - 2026-06-23
- builds: deletion is now an explicit, manual action decoupled from review status (#258). Marking a build Done no longer schedules it for deletion —
status_labelstays a pure review state and purge keys off a newdelete_aftertimestamp instead ofcompleted_at. Schedule or cancel viaPOST/DELETE /api/v1/builds/:id/schedule-deletion; build payloads now includedelete_after. Migration 012 grandfathers builds already on the old clock (delete_after = completed_at + TTL) so upgrades keep reclaiming disk. The App Center shows a deletion-countdown badge separate from the status column with explicit schedule/cancel controls. - relay: WebSocket heartbeat (ping/pong, 30s) terminates sockets that miss a pong window, so dead agent/browser/stream connections (Wi-Fi loss, sleep, cable pull) are detected promptly instead of lingering until the TCP timeout — evicting stale sessions and clearing the duplicate "Stale" card.
- ios:
capture-waitdiagnostic metric underTAPFLOW_STREAM_METRICS=1— the polling gap between an IOSurface change and when the frame is encoded, emitted per 150-sample window. Capture behavior is unchanged.
- cli:
tapflow setupreports per-step state (found / created / repaired) instead of a binary result, so you can see which prerequisites were already in place versus newly provisioned. Android SDK env registration that was already present is reported as "repaired" rather than "found". - relay: build-upload validation errors are returned in English, matching the rest of the API (previously the
.app.zipformat, missing-.app-directory, and device-only-slice messages were Korean only).
0.9.2 - 2026-06-20
- cli: unify the stream-quality tier label to "Smooth".
- cli:
tapflow startnow wires TLS likerelay start, so the all-in-one path can serve HTTPS/WSS for secure-context streaming (Smooth/WebCodecs) to LAN teammates — previously onlyrelay startdid. The co-located agent trusts the localhostwss://cert only (it never leaves the machine); external relays keep full verification. - cli: include
--tokenin the agent connect hint for remote relays. - agent: prevent display sleep by default (
caffeinate -di) so the host Mac keeps streaming during a session. - relay/agents: dedup agent re-register by machine id, removing duplicate "Stale" cards.
- relay: reject in-flight screenshots when an agent is evicted on re-register.
- ios: 16-align downscaled encode dimensions to remove the WASM (tinyh264) green edge on the no-downscale tier.
- Bump nodemailer to 9.0.1 — the message-level
rawoption bypasseddisableFileAccess/disableUrlAccess, enabling arbitrary file read and full-response SSRF (GHSA-p6gq-j5cr-w38f). relay uses a plain SMTP send path, so real-world exposure was nil. - Bump undici to 7.28.0 (TLS certificate validation bypass via SOCKS5 ProxyAgent, GHSA-vmh5-mc38-953g) and override dompurify to 3.4.11 (
ALLOWED_ATTRpollution viasetConfig(), GHSA-cmwh-pvxp-8882) — both dev/build-only transitive dependencies. Remove an orphaned dashboard lockfile the security graph scanned as a duplicate manifest.
0.9.1 - 2026-06-18
- relay: every secret can now live in
.tapflow-data/.env, not just DNS/ACME tokens. The relay loads.envbefore reading its config, soJWT_SECRET, the SMTP password, and the tunnel token are picked up from there too. Precedence is shell env >.env> config file (a shell variable still overrides the file);TAPFLOW_DATA_DIRis the exception since it determines where.envlives.
0.9.0 - 2026-06-17
- LAN HTTPS: the relay terminates TLS in-process with automatic certificates — Let's Encrypt via DNS-01 (Cloudflare / Vercel) or bring-your-own — backed by a disk certificate store with automatic renewal. It auto-publishes the detected LAN IP to the configured domain's A record and self-heals it so the HTTPS hostname keeps resolving on the local network.
tapflow initgains a guided HTTPS setup step; DNS/ACME credentials load from a gitignored.envfile namespaced underTAPFLOW_. This enables WebCodecs-based low-latency streaming, which requires a secure context. Requires Node >= 20.12.0. - dashboard: a performance-mode indicator in the session info strip shows the active decode path, with a Standard-mode upgrade notice.
- relay: upload size limits are configurable via
TAPFLOW_MAX_BUILD_BYTES/TAPFLOW_MAX_COMMENT_BYTES.
- relay: serves brotli-precompressed static assets with immutable caching for faster dashboard loads.
- dashboard: route-level code splitting (
React.lazy) and a lighter chart stack (visx, replacing recharts) shrink the initial bundle; variable fonts are trimmed to woff2 + latin subsets. - relay: hardened for public exposure — CORS is restricted to the configured origins instead of
*, cookie-authenticated state-changing requests need a same-origin / allowlisted origin (lightweight CSRF guard; PAT requests exempt), and invite links are built from the configured base URL instead of the requestHostheader.
- relay: handler exceptions are logged (method, path, stack) instead of silently swallowed, so 5xx failures are diagnosable. Response bodies still return a generic message and PATs are masked.
- relay: robust
Accept-Encodingnegotiation for static assets.
- Bump esbuild, hono, and other transitive dependencies to clear open Dependabot advisories. Add
.github/dependabot.ymlfor weekly grouped updates, excluding semver-major (reviewed manually).
0.8.2 - 2026-06-13
- relay: a per-install JWT secret is now generated and persisted automatically when
JWT_SECRETis unset, replacing the shared development default. No action is needed for a single relay; setJWT_SECRETonly to share one key across multiple instances. - relay: login attempts are rate-limited with exponential backoff (per IP + account).
- relay: first-time bootstrap (
auth/init) is restricted to localhost. On headless servers, runtapflow admin initon the relay host.
- relay:
TAPFLOW_TRUSTED_PROXIES— when the relay runs behind a same-host reverse proxy, set this so it resolves the real client IP fromX-Forwarded-Forinstead of treating every proxied client as localhost. Configure the proxy to forwardX-Forwarded-For.
0.8.1 - 2026-06-12
- relay: agents connecting from another machine now authenticate with a token. A relay only accepts a remote agent that presents a PAT with the new
agentscope (create one in Settings → Tokens; pass it viatapflow agent start --tokenorTAPFLOW_AGENT_TOKEN). Agents on the same machine as the relay (localhost, e.g.tapflow start) stay unauthenticated. See Remote relay authentication. - ios:
tapflow agent start --deviceis a relay-exposure filter (which simulators are offered), not a boot target.connectno longer pre-boots a simulator — booting stays on-demand via the dashboard.
- relay: restore remote agent connections (#271). A prior security fix closed every non-loopback WebSocket without a credential, so no remote agent could register and the agent hung at "Connecting ios agent…". Remote agents connect again, authenticated; the agent also fails fast with a clear reason instead of hanging on a rejected or malformed handshake.
- relay: bind dual-stack (IPv4 + IPv6) so an agent on another Mac connecting over
ws://<ipv4>:4000no longer times out (#269). - ios: auto-recover a simulator whose data directory vanished from disk (an Xcode/macOS update can prune it) — the agent erases and retries the boot once instead of failing.
0.8.0 - 2026-06-11
- cli:
tapflow setup [platform]— guided, one-pass environment setup. Auto-detects platforms when run without an argument. iOS opens the App Store for Xcode, activates it (license / first-launch), and downloads a simulator runtime. Android installs a JDK and builds a self-contained SDK at~/Library/Android/sdk(command-line tools, platform-tools, emulator, system image — no Android Studio GUI), then creates a set of AVDs across form factors. Booting stays on-demand via the relay. - cli:
tapflow doctor [platform]— checks a single platform or all. iOS shows Xcode / simctl / Simulator; Android shows SDK / adb / AVD (symmetric).--jsonemits machine-readable output; a device/AVD only needs to exist, not be running.
- cli:
doctorreports a missing prerequisite as a failure consistently across iOS and Android, and no longer triggers the macOS Command Line Tools install popup on a machine without Xcode.
0.7.0 - 2026-06-08
- android: emulators now capture over gRPC and encode H.264 on the Mac host (VideoToolbox). The gRPC backend is the default for emulators (auto-detected, 30fps cap), with automatic scrcpy fallback; real devices continue to use scrcpy.
- streaming: unified per-session downscale. Resolution is chosen from the viewer's connection context — native on a secure context, 1280px on LAN-HTTP, 1000px external — and is tunable via
TAPFLOW_MAX_SIZEand the per-platform /_LAN/_EXTERNALoverrides. - relay: request an IDR keyframe when a browser (re)joins a booted device, so a late joiner paints immediately.
- dashboard: iOS/Android decoding and perf telemetry are unified behind a single
useDecoderStreamhook (hardware WebCodecs on a secure context, WASM fallback otherwise). - ios-agent: static-frame skip — unchanged H.264 frames are no longer re-sent.
- ios: tear-free framebuffer snapshots via a seed-stable copy, and keyframe-aware backpressure on the agent→relay stream.
- android: keyframe-aware backpressure on the agent→relay stream, and 16-aligned encode sizing to avoid macroblock padding on the WASM decoder.
0.6.1 - 2026-06-06
- android: fix a crash when the scrcpy video stream is cancelled. The v0.6.0 socket-close cleanup could call
close()/error()on an already-closed stream controller, throwing inside the socket event handler.
0.6.0 - 2026-06-06
- android: opt-in stream throughput metrics (
TAPFLOW_STREAM_METRICS=1) logging fps / KB·s / drop every 5s, matching the iOS agent. - agents: hold a macOS power assertion (
caffeinate -i) while connected so an unattended/idle Mac doesn't throttle the simulator/emulator. macOS-only; no-op elsewhere.
- android: H.264 frames now carry the codec/keyframe flags in the stream envelope, so the relay's keyframe-aware backpressure drops to the next keyframe under LAN congestion instead of forwarding P-frames that tear. (scrcpy
send_frame_meta=true; the publicstream()contract is unchanged.) - android: on-demand IDR recovery —
stream:request-idrnow resets the scrcpy encoder (RESET_VIDEO), resyncing fast instead of waiting for the periodic IDR (parity with iOS).
- android: the scrcpy stream now terminates on socket close, so the agent's pump and its timers no longer leak after a device shuts down.
0.5.1 - 2026-06-06
- android: screen rotation on Android 15+ (API 35+).
AdbWrapper.setRotationnow useswm user-rotation lockinstead of the legacysettings put system user_rotation, which newer Android silently ignores (only a rotation suggestion appears). The bundled scrcpy server is upgraded 3.1 → 3.3, fixing the locked capture-orientation direction (scrcpy #6010) that left the stream sideways after rotation. Verified on API 34 and API 36 emulators.
0.5.0 - 2026-06-04
- H.264 streaming pipeline: iOS streams H.264 by default via a VideoToolbox encoder, cutting bandwidth ~10× vs JPEG (~16–27 KB/frame vs ~235 KB) for noticeably lower latency. Android streaming moves to a runtime decoder layer.
- Automatic codec negotiation: the browser advertises its decode capability (
acceptH264) at boot; the agent picks H.264 only when the client can decode it, otherwise falls back to JPEG — no black screens on older browsers. Opt out withTAPFLOW_IOS_CODEC=jpeg. - Tiered browser decoders: HTTPS → WebCodecs, plain-HTTP LAN → WASM (tinyh264), both WebGL2-rendered.
- cli:
tapflow startprints the public tunnel URL banner (Tailscale MagicDNS host / tailnet IP auto-detected); a missing rathole token now falls back to local-only instead of exiting. - dashboard: 404 error page and rectangular auth submit button.
- envelope: codec/keyframe marker added to the frame header (byte5 flags). Backward compatible — older clients read frames as JPEG and the relay forwards payloads untouched; agents without
acceptH264(version skew) default to JPEG. - ios-agent: lower the default JPEG stream quality
0.95→0.8, cutting iOS frame bandwidth ~40% on idle/simple screens to reduce relay→browser frame drops on LAN. Tune with theTAPFLOW_JPEG_QUALITYenv var (0–1).
- android: landscape rotation and recording via a locked stream + local intent.
0.4.1 - 2026-06-01
- relay: fix path traversal in
/uploads/—serveUploadnow validates that the resolved file path stays withinuploadsDir; requests that escape the directory return 403. - relay:
/uploads/route now requires view authentication — unauthenticated requests return 401 before file serving. - relay: WebSocket connections from non-localhost clients without a valid JWT cookie or PAT are rejected with close code 1008.
- relay: WebSocket role gating — browser-role sockets that send agent-only messages (
agent:register,agent:resources, etc.) are disconnected immediately.
0.4.0 - 2026-06-01
tapflow initno longer creates an admin account. It now scaffoldstapflow.config.json.- Before:
tapflow start(auto-created config) →tapflow init(admin creation via CLI) - After:
tapflow init(scaffold config) →tapflow start→ open/setupin browser (admin creation) - Migrate: use the
/setuppage on first launch, ortapflow admin initin headless environments.
- Before:
tapflow startandtapflow relay startno longer createtapflow.config.jsonas a side effect. Runtapflow initexplicitly, or skip it to use built-in defaults (port 4000,.tapflow-data/).
tapflow init— scaffoldtapflow.config.jsoninteractively;--tunnel tailscale|ratholefor non-interactive mode;--forceto overwrite.tapflow initauto-updates.gitignore— creates the file if absent, appends.tapflow-data/if not already present.tapflow admin init— create the first admin account via CLI (headless / CI fallback).- Dashboard
/setuppage — web-based first admin account creation; auto-redirected from/loginwhen no accounts exist. GET /api/v1/auth/status— public endpoint returning{ initialized: boolean }.- Tailscale tunnel provider (
tunnel.provider: "tailscale") — E2E encrypted, no VPS required.
- Automatic
tapflow.config.jsoncreation as a side effect oftapflow start/tapflow relay start.