DEV Community

Duchan
Duchan

Posted on

The clipboard bridge that had to wait for the device

I wanted a copy button in tapflow's browser viewer. The obvious version is short:

navigator.clipboard.writeText(text)
Enter fullscreen mode Exit fullscreen mode

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.

Paste was the easy direction

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:

const text = event.clipboardData?.getData('text') ?? ''
event.preventDefault()

request('clipboard:write', { text, pasteAfter: true })
// wire payload: { type, sessionId, requestId, payload: { text, pasteAfter } }
Enter fullscreen mode Exit fullscreen mode

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 KEYCODE_PASTE event.

That distinction matters. Android is not receiving a simulated Cmd+V chord.

The path is:

browser paste event → relay → agent → device clipboard → paste input
Enter fullscreen mode Exit fullscreen mode

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 cannot simply call writeText later

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.

const original = await readDeviceClipboard()
const sentinel = `\u200Btapflow-clipboard-${randomUUID()}`

await writeDeviceClipboard(sentinel)
await waitUntil(() => readDeviceClipboard() === sentinel)
await sendCopyInput()

const copied = await waitUntil(async () => {
  const value = await readDeviceClipboard()
  return value.startsWith('\u200Btapflow-clipboard-') ? undefined : value
})
Enter fullscreen mode Exit fullscreen mode

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 browser has to claim the clipboard before the result exists

The first implementation guessed with a fixed delay (COPY_SETTLE_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:

function claimClipboard(pending: Promise<string>): Promise<void> {
  const blob = pending.then(
    (text) => new Blob([text], { type: 'text/plain' })
  )

  return navigator.clipboard.write([
    new ClipboardItem({ 'text/plain': blob })
  ])
}
Enter fullscreen mode Exit fullscreen mode

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 timeout bug was a coordination bug

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.

The current values are derived from the normal slow path:

const AGENT_WORST_MS = 1_000 + 2_000 + 5 * 300
const ROUND_TRIP_BUDGET_MS = AGENT_WORST_MS + 500
Enter fullscreen mode Exit fullscreen mode

The agent budget is 4.5 seconds: one second to confirm the device write, two seconds to observe the copy, and five observed device calls at 300 ms each. The browser waits five seconds, leaving a 500 ms margin.

Those values are an operating budget, not a hard upper bound. A wedged simulator can take longer because individual simctl and emulator calls have their own five-second deadlines. The budget is meant to let a normally slow device answer before the browser stops listening.

There is another detail hidden in the five calls. The polling window checks the deadline after a call returns, and the workflow also has three fixed device calls: reading the original clipboard, preparing for the copy chord, and writing the sentinel. The count is therefore five for this budget, rather than four.

The agent's 4.5-second worst case and that six-second observation leave a 1.5-second window to place the browser's budget in. It sits at five seconds, one second below the observed limit. The six-second figure is a measurement of Chrome and Safari holding the claimed clipboard write, without recorded browser versions. I treat it as an observation from our implementation work, not a browser guarantee.

What this bridge does not support

The bridge carries text/plain only. Images and files are not included, and text is limited to 1 MB of UTF-8 data. Android uses the emulator gRPC clipboard channel; physical Android devices are outside this bridge's supported path.

The feature started as a copy button. It became a small protocol for proving that a device really copied something, preserving the user's clipboard when it did not, and keeping a browser user gesture alive long enough to receive an asynchronous answer.

The implementation lives in tapflow's dashboard and agent packages. The source is available in the tapflow repository. The clipboard behavior described here is unchanged from v0.17.0 through v0.22.0.

Top comments (0)