I wanted a copy button in tapflow's browser viewer. The obvious version is short:
That works when text is already in the browser. Our text was on a simulator, so the browser had to ask the device to copy first, wait for the answer, and only then put that answer on the user's clipboard.
That small difference exposed two separate problems: device clipboards are asynchronous, and the browser decides whether it is allowed to write the clipboard before the answer arrives.
For paste, the browser already owns the text. The paste event gives us a string, and tapflow sends it through the relay to the agent:
The agent writes the device clipboard, waits until that write is visible, and triggers paste on the device. The last step is platform-specific: iOS receives a HID KeyV plus the Meta modifier, the equivalent of Cmd+V. Android receives the emulator gRPC clipboard update and then the dedicated KEYCODEPASTE event.
The agent performs the final input because the browser cannot know when the text has actually reached the simulator. On iOS, the software keyboard may also need to disappear before the key can be delivered.
Copy runs in the opposite direction. The viewer sends a copy command to the device, then needs to receive the newly copied value. A fixed sleep is unsafe: if the device is slow, the old clipboard value may still be there. Returning that old value looks like a successful copy.
tapflow writes a random sentinel to the device clipboard first. It reads and keeps the user's original clipboard value, writes the sentinel, waits until the sentinel is visible, and sends the device copy command. It then polls until the clipboard is no longer the sentinel. That changed value is the application's copy result.
The sentinel also handles copying the same text twice. Comparing only the returned text would miss that case.
If the copy fails, tapflow restores the original clipboard and waits for that restoration to become visible. The device is serialized while this marker is parked; another clipboard operation entering midway could mistake the marker for its own.
The first implementation guessed with a fixed delay (COPYSETTLE_MS = 60), then raised it to 120 ms. That still could not prove that the device clipboard had changed. The replacement was titled fix(clipboard): prove the copy landed instead of guessing a delay.
The next attempt waited for the device response and called navigator.clipboard.writeText afterward. Safari can reject that because the original key press is no longer considered an active user gesture. In our earlier measurement, execCommand('copy') worked at 500 ms and failed at 1,000 ms, even while userActivation.isActive still read true. Browser versions were not recorded, so I treat those timings as observations from our implementation work rather than browser guarantees. execCommand('copy') also needs the value synchronously.
The solution is a promise-backed ClipboardItem. The dashboard claims the clipboard during the keydown handler, while its payload is still pending:
The browser checks this path with window.isSecureContext, ClipboardItem, and navigator.clipboard.write. It does not check the URL scheme directly. http://localhost is a secure context; a dashboard opened at an address such as http://192.168.x.x is not.
On a non-secure LAN page, the device still receives the copy command and its own clipboard is updated. The browser cannot receive the result, so the viewer reports:
Copied on the device. Serving the dashboard over HTTPS also brings it to your clipboard.
That message is more useful than pretending the operation failed. The device did copy; only the browser-side half was unavailable.
The original browser budget was 3,000 ms. The agent's write and copy deadlines added up to another 3,000 ms, but device-call time was not included. The browser could give up just before the agent produced its more specific answer, replacing it with a generic timeout.
