Trawling

디바이스를 기다려야 했던 클립보드 브리지

tapflow가 브라우저와 시뮬레이터 사이에서 텍스트를 복사·붙여넣기하는 과정을 비동기 프로토콜로 구현한 사례를 설명합니다. 고정 지연 대신 sentinel과 상태 확인을 사용하고, 브라우저의 사용자 제스처·보안 컨텍스트·타임아웃 제약까지 함께 다룹니다.

본문

tapflow의 브라우저 뷰어에 복사 버튼을 추가하는 작업은 단순한 navigator.clipboard.writeText(text) 호출로 끝나지 않았습니다. 텍스트가 브라우저 안에 있는 것이 아니라 시뮬레이터에 있었기 때문에, 브라우저가 디바이스에 복사를 요청하고 결과를 기다린 뒤 그 값을 다시 사용자의 브라우저 클립보드에 기록해야 했습니다. 이 과정에서 디바이스 클립보드는 비동기적으로 갱신되고, 브라우저는 비동기 응답이 도착한 시점에 클립보드 쓰기를 허용하지 않을 수 있다는 두 가지 문제가 드러났습니다.

붙여넣기 경로: 브라우저 이벤트에서 디바이스 입력까지

붙여넣기는 브라우저가 이미 텍스트를 소유하고 있기 때문에 상대적으로 경로가 명확합니다. 브라우저의 paste 이벤트에서 clipboardData의 text 값을 읽고 기본 동작을 막은 다음, relay를 통해 agent에 clipboard:write 요청을 보냅니다. wire payload에는 type, sessionId, requestId, 그리고 text와 pasteAfter를 포함한 payload가 들어갑니다. agent는 먼저 디바이스 클립보드에 텍스트를 기록하고, 해당 기록이 실제로 보일 때까지 기다린 뒤 디바이스에서 붙여넣기 입력을 발생시킵니다.

최종 입력 방식은 플랫폼마다 다릅니다. iOS에서는 Meta modifier와 HID KeyV를 보내며, 이는 Cmd+V에 해당합니다. Android에서는 emulator gRPC clipboard 업데이트를 수행한 뒤 전용 KEYCODE_PASTE 이벤트를 보냅니다. 따라서 Android에 시뮬레이션된 Cmd+V 키 조합을 보내는 방식은 아닙니다. 전체 경로는 브라우저 paste event → relay → agent → device clipboard → paste input으로 이어집니다. 브라우저는 텍스트가 시뮬레이터에 실제로 도착한 시점을 알 수 없으므로 agent가 마지막 입력을 담당합니다. iOS에서는 키를 전달하기 전에 소프트웨어 키보드가 사라져야 하는 경우도 있습니다.

복사 경로: 고정 지연 대신 sentinel로 변경을 증명하기

복사는 반대 방향으로 동작합니다. 뷰어가 디바이스에 복사 명령을 보내고, 디바이스 클립보드에 새로 들어온 값을 받아 브라우저 클립보드에 기록해야 합니다. 처음에는 COPY_SETTLE_MS를 60밀리초로 두고, 이후 120밀리초로 늘리는 고정 지연을 사용했습니다. 그러나 디바이스가 느리면 지연 시간이 끝났을 때도 이전 클립보드 값이 남아 있을 수 있습니다. 그러면 실제 복사가 완료되지 않았는데도 이전 값이 성공 결과처럼 반환됩니다.

이를 해결하기 위해 tapflow는 sentinel이라는 임시 문자열을 사용합니다. 먼저 사용자의 기존 디바이스 클립보드 값을 읽어 보존하고, \u200Btapflow-clipboard-${randomUUID()} 형태의 무작위 sentinel을 기록합니다. sentinel이 디바이스에서 실제로 보일 때까지 기다린 다음 디바이스 복사 입력을 보냅니다. 이후 클립보드를 반복해서 읽으면서 값이 sentinel이 아닌 값으로 바뀌기를 기다립니다. sentinel에서 다른 값으로 바뀌었다는 사실이 디바이스의 복사 결과가 발생했다는 증거가 됩니다.

이 방식은 같은 텍스트를 연속으로 두 번 복사할 때도 필요합니다. 반환된 텍스트만 비교하면 두 번째 복사에서 값이 변하지 않았다는 이유로 새 복사를 감지하지 못할 수 있지만, sentinel을 먼저 심으면 같은 텍스트라도 sentinel에서 실제 텍스트로의 전환을 관찰할 수 있습니다. 복사가 실패하면 tapflow는 원래 클립보드 값을 복원하고, 복원된 값이 디바이스에 보일 때까지 기다립니다. sentinel이 디바이스에 남아 있는 동안에는 다른 클립보드 작업이 중간에 들어오지 않도록 해당 디바이스 작업을 직렬화합니다. 그렇지 않으면 다른 작업이 자신의 sentinel이 아닌 임시 값을 복사 결과로 오인할 수 있습니다.

브라우저 사용자 제스처를 유지하는 ClipboardItem

디바이스 응답을 기다린 뒤 navigator.clipboard.writeText를 호출하는 방식에도 브라우저 제약이 있습니다. Safari는 원래 키 입력이 발생한 뒤 시간이 지나면 해당 호출을 활성 사용자 제스처로 인정하지 않고 거부할 수 있습니다. 구현 과정에서 관찰한 결과 execCommand('copy')는 500밀리초에서는 동작했지만 1,000밀리초에서는 실패했으며, 이때 userActivation.isActive는 여전히 true로 읽혔습니다. 다만 브라우저 버전은 기록되지 않았으므로 이 수치는 브라우저의 보장된 임계값이 아니라 해당 구현에서 관찰된 값으로 취급합니다. execCommand('copy')는 값도 동기적으로 제공받아야 한다는 제약이 있습니다.

해결책은 아직 완료되지 않은 Promise를 ClipboardItem의 payload로 사용하는 방식입니다. keydown 핸들러가 실행되는 동안 대시보드가 브라우저 클립보드를 먼저 점유하고, 실제 텍스트가 도착하면 Promise가 Blob으로 변환되도록 합니다. 구현은 pending Promise를 text/plain Blob으로 만들고, navigator.clipboard.write에 text/plain ClipboardItem을 전달합니다. 이렇게 하면 브라우저가 사용자 제스처를 처리하는 순간에 클립보드 쓰기 작업을 시작해 두고, 이후 디바이스의 비동기 결과를 payload로 채울 수 있습니다.

보안 컨텍스트와 부분 성공 처리

브라우저는 window.isSecureContext, ClipboardItem, navigator.clipboard.write의 존재 여부를 확인합니다. URL 스킴 자체를 직접 확인하는 것은 아닙니다. http://localhost는 secure context로 취급되지만, http://192.168.x.x와 같은 LAN 주소에서 제공되는 대시보드는 secure context가 아닙니다.

보안 컨텍스트가 아닌 LAN 페이지에서도 디바이스 쪽 복사 명령은 실행되고 디바이스 클립보드도 갱신될 수 있습니다. 다만 브라우저가 그 결과를 받아 자신의 클립보드에 기록할 수 없습니다. 이 경우 뷰어는 작업 전체를 실패했다고 표시하지 않고, “Copied on the device. Serving the dashboard over HTTPS also brings it to your clipboard.”라는 메시지를 표시합니다. 즉 디바이스 복사는 성공했지만 브라우저 측 절반은 사용할 수 없다는 상태를 구분해 알려줍니다.

타임아웃 예산과 호출 횟수

초기 브라우저 타임아웃은 3,000밀리초였습니다. agent의 디바이스 쓰기와 복사 제한 시간을 합치면 추가로 3,000밀리초가 필요했지만, 실제 디바이스 호출에 걸리는 시간은 계산에 포함되지 않았습니다. 그 결과 브라우저가 agent의 더 구체적인 응답이 도착하기 직전에 먼저 포기하고 일반적인 타임아웃을 반환할 수 있었습니다.

현재 예산은 정상적으로 느린 경로를 기준으로 계산합니다. agent의 최악 예상 시간은 1,000밀리초의 디바이스 쓰기 확인, 2,000밀리초의 복사 관찰, 그리고 300밀리초씩 다섯 번 수행되는 디바이스 호출을 합한 4.5초입니다. 브라우저의 왕복 예산은 여기에 500밀리초를 더한 5초로 설정합니다. 이 값은 절대적인 상한이 아니라, 정상적으로 느린 디바이스가 응답하기 전에 브라우저가 연결을 끊지 않도록 하는 운영 예산입니다. 멈춘 시뮬레이터에서는 simctl과 emulator 호출 자체의 5초 제한 때문에 더 오래 걸릴 수 있습니다.

폴링 창은 호출이 반환된 뒤 데드라인을 확인하며, 별도로 원래 클립보드 읽기, 복사 입력 준비, sentinel 기록이라는 세 번의 고정 디바이스 호출도 수행합니다. 따라서 예산 계산에서 호출 횟수는 네 번이 아니라 다섯 번으로 잡습니다. agent의 4.5초 최악 경로와 약 6초의 관찰 한계 사이에서 브라우저 예산은 5초에 위치하며, 1초의 여유를 둡니다. Chrome과 Safari가 claimed clipboard write를 유지하는 시간으로 관찰한 6초 역시 브라우저 버전이 기록되지 않은 구현 관찰값이며 보장값은 아닙니다.

지원 범위

이 브리지는 text/plain만 전달하며 이미지와 파일은 지원하지 않습니다. 텍스트는 UTF-8 기준 1MB로 제한됩니다. Android에서는 emulator gRPC clipboard channel을 사용하므로 실제 물리 Android 디바이스는 지원 경로에 포함되지 않습니다. 기능은 단순한 복사 버튼에서 출발했지만, 최종적으로는 디바이스에서 실제 복사가 발생했는지 증명하고, 실패 시 사용자의 클립보드를 보존하며, 비동기 응답을 받을 때까지 브라우저 사용자 제스처를 유지하는 작은 프로토콜로 확장되었습니다. 구현은 tapflow의 dashboard와 agent 패키지에 있으며, 설명된 클립보드 동작은 v0.17.0부터 v0.22.0까지 동일하게 유지됩니다.

원문: dev.to / 번역·요약: Trawling

참고 자료

오류 제보