Steven
Steven7 분 소요

클릭 통과 오버레이의 두 가지 실패 모드

GeekBye의 창은 모든 것 위에 떠 있으면서, 버튼이 있는 곳을 빼고는 당신의 클릭이 그것을 그대로 통과하게 둡니다. 그것은 양면 계약이고, v1.8.5와 v1.8.14는 각 면이 깨질 때 어떤 모습인지를 보여 줍니다: 한 릴리스에서는 오버레이가 시스템 대화상자를 삼켰고, 다른 하나에서는 당신의 키 입력을 훔쳤습니다. 두 번째를 이긴 해결책은 코드를 지우는 것이었습니다.

엔지니어링
Electron
데스크톱
GeekBye 릴리스
클릭 통과 오버레이의 두 가지 실패 모드

GeekBye의 메인 창은 평범한 창이 아닙니다. 프레임이 없고, 투명하고, 항상 위에 있습니다 — 당신이 무엇을 하고 있든 그 위에 떠 있는 한 장의 유리판이고, 그 위에 몇 개의 컨트롤이 그려져 있습니다. 흥미로운 엔지니어링은 그것이 떠 있다는 게 아닙니다; 당신의 마우스가 컨트롤을 뺀 모든 곳에서 유리를 곧장 통과해야 한다는 것입니다. 오버레이 위의 버튼을 클릭하면 오버레이가 그 클릭을 받습니다. 그 옆의 빈 공간을 클릭하면, 마치 오버레이가 거기 없는 것처럼, 클릭은 아래에 있는 당신의 회의 앱에 내려앉습니다.

그것은 두 면을 가진 계약입니다: UI가 있는 바로 그곳에서 입력을 받고, 그 밖의 모든 곳에서는 입력에 완전히 보이지 않을 것. 두 면 모두 매 순간 지켜져야 하고, 앱의 이 부분에 있는 모든 버그는 둘 중 한 면의 위반입니다. 두 달 떨어진 두 릴리스는 가능한 한 가장 깔끔한 예시입니다 — 각각이 서로 다른 면을 깨뜨렸기 때문입니다.

계약을, 기계적으로

구현은 useMousePassthrough라는 훅이고, 작습니다. 창은 기본적으로 클릭 통과입니다. 전역 mousemove 리스너가 움직임마다 하나의 질문을 던집니다: 커서가 컨트롤 위에 있나? 컨트롤에는 data-ui-region 속성 태그가 붙어 있어서, 검사는 문자 그대로 target.closest('[data-ui-region]')입니다. 컨트롤 위라면 setIgnoreMouseEvents(false)를 호출하고 창이 클릭을 붙잡습니다. 투명한 공간 위라면 setIgnoreMouseEvents(true, { forward: true })를 호출하고 클릭은 통과해 떨어집니다.

forward: true가 하중을 떠받치는 디테일입니다. 창이 일단 마우스 이벤트를 무시하기 시작하면, 보통은 그것들을 완전히 받지 않게 됩니다 — 훅이 커서가 컨트롤 위로 되돌아오는 것을 알아차리는 데 필요한 mousemove 이벤트까지 포함해서. forward: true는 Electron에게 그래도 무시 중인 창에 움직임을 계속 전달하라고 말해서, 창이 자신을 다시 무장할 수 있게 합니다. 그것이 없으면 오버레이는 한 번 클릭 통과가 되고 다시는 돌아오지 않습니다. 훅은 또한 상태 전이 때만 — 커서가 UI와 유리 사이의 경계를 넘을 때만 — IPC 경계를 넘어 호출하고, 움직임의 픽셀마다 하지는 않습니다. 그 최적화를 기억해 두세요; 거기엔 구멍이 있고, v1.8.5가 그 안에 빠졌습니다.

v1.8.5: 투명성 쪽이 깨질 때

계약의 투명성 쪽은 "UI가 없는 모든 곳에서 입력에 보이지 않을 것"입니다. v1.8.5는 오버레이가 너무 앞에 나설 때 — 통과시켜 줬어야 할 입력을 붙잡거나 막을 때 — 어떤 모습인지를 보여 줍니다. 방아쇠는 macOS 권한 대화상자였고, 서로의 위에 쌓인 두 개의 뚜렷이 다른 실패가 있었습니다.

오버레이가 물리적으로 대화상자 위에 있었다. 항상 위에 있는 창에는 _레벨_이 있고, GeekBye의 오버레이는 macOS가 제공하는 가장 높은 레벨 — screen-saver — 에 앉아 있었습니다. 그것은 시스템 자신의 Microphone과 Screen Recording 권한 대화상자를 포함해, 거의 모든 것 위입니다. 그래서 온보딩 중, macOS가 권한을 물어보는 바로 그 순간에, 대화상자는 오버레이 아래에 그려져 보이지 않을 수 있었습니다. 해결책인 setPermissionMode는 macOS 전용이고 직관에 반하는 일을 합니다: 권한 요청이 지속되는 동안 오버레이를 floating 레벨로 내리고, 그 뒤에 복원합니다. 그리고 복원하려면 특정한 트릭이 필요했는데, 그건 커밋에 그대로 기록돼 있습니다: macOS는 "이전에 있던 것과 같은 레벨을 설정할 때 창을 언제나 시각적으로 다시 올려 주지는 않는다"는 것, 그래서 코드는 창을 중간 레벨을 거쳐 단계별로 옮깁니다 — floating → status → screen-saver — 윈도우 서버가 쌓임 순서를 다시 계산하게 강제하려고요. 원하는 레벨을 그냥 설정하는 게 아닙니다; 그 레벨로 걸어가는 것입니다.

오버레이가 대화상자의 클릭을 먹고 있었다. 온보딩은 창 전면의 어두운 백드롭을 그리고, 통과 훅은 그것을 특별 취급합니다: 그 백드롭이 떠 있는 동안, 마법사가 완전히 상호작용 가능하도록 창 전체에 클릭 붙잡기를 강제합니다. 하지만 "모든 클릭을 붙잡기"는 시스템 대화상자가 하나를 모으려 할 때는 정확히 틀린 일입니다. 해결책은 권한 요청 동안 백드롭의 표식 속성을 벗겨내서, 훅이 붙잡기 모드에서 빠져나오고 대화상자의 버튼이 자기 클릭을 받게 합니다. 오버레이는 자기 컨트롤은 상호작용 가능한 채로 유지합니다; 그저 빈 유리가 버튼인 척하는 것을 멈출 뿐입니다.

최적화에 있던 구멍. 훅은 전이 때만 움직인다는 걸 기억하세요. 창이 붙잡기 모드를 떠났을 때, 그것은 호버 검사를 다시 돌렸습니다 — 하지만 만약 그 순간 당신의 커서가 이미 컨트롤 위에 앉아 있었다면, "우리가 UI 위에 있나?"의 답은 이미 "예"였으므로, 아무 전이도 발화하지 않았고, 클릭 통과는 한 번도 꺼지지 않았습니다. 컨트롤은 살아 있는 것처럼 보였지만 죽어 있었습니다. 해결책(1b6eb20, 인용)은 전이를 믿는 것을 멈춥니다: "when exiting onboarding mode, directly evaluate whether cursor is over a UI region and make the correct IPC call." 모드를 바꿀 때, 다음 움직임이 커서가 어디 있는지 말해 주기를 기다리지 마세요 — 보세요.

그리고 양보를 되감을 수 있게 만들어라. 이 모든 것에 잠긴 미묘한 위험은, 오버레이가 이제 일시적으로 자기 z-순서와 클릭 붙잡기를 내려놓았고, 무언가가 그것들을 돌려 줘야 한다는 것입니다. 복원 호출이 예외를 던지면 — IPC 딸꾹질, 요청 도중에 헐린 창 — 앱은 isRequestingPermission이 true인 채로 붙박이고, 어두운 백드롭이 당신의 온 화면을 영원히 가린 채, 좌초될 수 있습니다. 그래서 종료 경로는 상태 리셋이 "언제나 실행되도록" 감싸졌고, 거기에 몇 분짜리 안전 타임아웃과 창 파괴 시의 정리가 더해졌습니다. 거기서 떨어지는 규칙: 오버레이가 입력을 양보할 때마다, 그 양보의 되감기는 해피 패스가 아니라 finally 안에 있어야 한다.

v1.8.14: 붙잡는 쪽이 깨질 때

계약의 다른 쪽은 "UI가 있는 곳에서 입력을 받아라"입니다. v1.8.14는 오버레이가 너무 움켜쥘 때 — 그것과 상호작용하는 것이 당신이 실제로 신경 쓰는 앱의 입력 포커스를 대가로 앗아 갈 때 — 어떤 모습인지를 보여 줍니다.

changelog 속 증상은 포커스 훔치기이고, 여기 정직한 버전을 적습니다. git 히스토리가 릴리스 노트보다 더 배울 게 많기 때문입니다. 오버레이의 단축키 핸들러들 — Cmd+B로 표시 토글, 채팅 창 열기, 그것을 스크롤하기 — 은 각각 예전에 mainWindow.focus()를, 그리고 macOS에서는 app.focus({ steal: true })를 호출해서, 당신이 그것을 발동했을 때 오버레이가 맨 앞에 있도록 했습니다. 문제는 오버레이에 포커스를 주는 것이 _그 전에 맨 앞이던 무엇이든 비활성화한다_는 것입니다 — 당신의 회의, 당신의 에디터 — 그래서 그것들을 향한 키 입력이 아무 데도 내려앉지 못하거나, 엉뚱한 곳에 내려앉을 수 있었습니다. 오버레이는 실제로 필요하지도 않은 것을 움켜쥐고 있었습니다.

그 마지막 부분이 교훈의 전부이고, 팀은 그것을 어렵게 찾았습니다. v1.8.14가 된 브랜치는 처음에 드래그 그 자체를 풀려고 했습니다 — 드래그에는 이 버그의 아름다운 자기만의 버전이 있기 때문입니다: 전체 화면 오버레이를 드래그하면, 멈춰 있는 커서 아래에서 창이 움직여서, 호버 모델은 포인터가 컨트롤을 "떠났다"고 생각하고 드래그 도중에 클릭 통과로 뒤집혀, 드래그를 떨어뜨립니다. 그래서 그들은 dragLock을 더했습니다. 그다음 창 전체를 포커스 불가능하게 만들어 봤는데 — 그게 타이핑과 드래그를 깨뜨려서, 포커스 가능성을 복원하고, 그다음 포커스 불가능성을 텍스트 입력만으로 좁히고, 그다음 그 토글을 뜯어냈습니다. 그들은 Electron의 네이티브 드래그를 대체할 커스텀 드래그 IPC를 만들었습니다. 예기치 못한 포커스 시의 블러를 더했습니다. 이런 일이 일주일.

그러고 나서 그들은 그 거의 전부를 지웠습니다. 출시된 커밋은 커스텀 드래그, 포커스 가능 토글, 블러 핸들러를 되돌렸고, 정확히 하나의 아이디어만 남겼습니다: focus()를 부르지 말라. 코드 주석에 적힌 그 깨달음은, 오버레이가 애초에 OS 포커스를 전혀 필요로 하지 않았다는 것입니다 — "IPC messages (webContents.send) work without window focus." 오버레이는 자기 렌더러를 직접 몹니다; 포커스는 순수한 부수 피해였습니다. 출시 가능한 해결책은 하루 만에 만든 영리한 커스텀 드래그 재작성이 아니었습니다. 거기 있어서는 안 됐던 포커스 호출을 빼는 것이었습니다. (분명히 말할 값어치가 있습니다: changelog는 "while dragging it"이라고 하지만, 실제로 출시된 것은 키보드 단축키 핸들러에서 포커스 훔치기를 제거하는 것이었습니다 — 드래그 전용 기계 장치는 같은 주에 탐색됐고 그리고 되돌려졌습니다.)

판돈을, 문자 그대로

이 계약이 탁상공론이 아니라는 증거를 원한다면, 몇 달 뒤 오버레이가 그 계약의 잘못된 쪽에 갇혔을 때 무슨 일이 일어났는지를 보세요. 훨씬 나중의 릴리스에서, 오버레이가 붙잡기 모드 — setIgnoreMouseEvents(false), 창이 모든 클릭을 움켜쥔 상태 — 에 있는 동안 렌더러 프로세스가 크래시했습니다. 그리고 그것을 놓아 줄 살아 있는 UI가 없으니, 그 보이지 않는 데스크톱 전면 창은 거기 눌러앉아, 사용자가 강제 종료할 때까지 그 기계 위의 모든 클릭을 먹어 치웠습니다. 붙잡기 모드에 갇힌 투명한 창은 죽은 버튼이 아닙니다; 그것은 데스크톱 전체를 덮는 클릭 덫입니다. (그것은 자기만의 진단을 받았습니다 — AI 회의록 앱이 회의 도중 녹음을 멈출 때를 보세요. 거기서는 같은 릴리스 안의 아주 다른 버그와 그것이 짝을 이룹니다.) 그것은 문제 전체에 대한 가장 날카로운 진술입니다: 토글을 한 방향으로 갇히게 하면 당신의 UI가 죽고; 다른 방향으로 갇히게 하면 당신의 데스크톱 전체가 죽습니다.

이 릴리스들이 우리에게 가르쳐 준 세 가지

  1. 클릭 통과 오버레이는 양면 계약이고, 두 면은 서로 다르게 실패한다. 너무 투명하면 당신 자신의 컨트롤이 죽고; 너무 움켜쥐면 다른 앱들의 입력을 삼킨다. 대부분의 오버레이 버그는 그 둘 중 하나일 뿐이고, 자신이 어느 면에 있는지를 이름 붙이는 것이 어디를 봐야 할지 알려 준다.
  2. 항상 위 중 가장 높은 레벨은 상이 아니다. 모든 것 위에 앉는다는 건 사용자가 봐야 하는 시스템 대화상자 위에 앉는다는 뜻이다. 당신의 오버레이가 언제 정중히 한 걸음 물러나야 하는지를 내다보고 설계하라 — 그리고 다시 한 걸음 올라오는 것을 finally 안에서 되감을 수 있게 만들어라. 절반만 완료된 양보는 아예 양보하지 않는 것보다 나쁘기 때문이다.
  3. 뺄셈이 영리함을 이긴다. 이긴 v1.8.14의 해결책은 오버레이가 한 번도 필요로 하지 않은 focus() 호출을 지우는 것이었고, 커스텀 드래그 처리를 만들고 되돌리는 일주일 뒤에야 발견됐다. 어떤 기능이 플랫폼과 계속 싸울 때, 가장 먼저 물어야 할 질문은 싸움을 하는 그 물건을 제거할 수 있느냐다.

v1 이야기의 이전 장은 릴리스를 CI에 넣다, 두 번(v1.8.4)을 보시고; 전체 궤적은 소프트웨어를 완벽하게 출시하는 것의 해부학을 보세요.

관련 글

OCR에서 픽셀로, 폴백을 잃지 않으면서
Steven
Steven6 분 소요

OCR에서 픽셀로, 폴백을 잃지 않으면서

GeekBye는 스크린샷을 OCR로 읽는 것을 멈추고 실제 이미지를 vision 모델로 보내기 시작했습니다 — 그건 쉬운 부분입니다. 배울 만한 부분은 vision 사용자에 대해 OCR을 통째로 건너뛴 최적화입니다. 그것은 vision 비지원 모델로의 폴백을 조용히 망가뜨렸고, 안전망이 병렬로 돌면서 아무 비용도 들지 않는 버전으로 되돌려야 했습니다.

엔지니어링
AI
데스크톱
릴리스를 CI에 넣다, 두 번
Steven
Steven6 분 소요

릴리스를 CI에 넣다, 두 번

GeekBye v1.8.4는 릴리스 빌드를 macOS와 Windows 모두 CI로 옮겼습니다. 변경 로그가 말하지 않는 것은, 이 일을 이전에 한 번 시도했다가 러너 비용 때문에 한 달 뒤 지워 버렸고, 두 번째에서야 자리 잡게 했다는 사실입니다 — 그때쯤엔 릴리스 스크립트가 이미 손으로 잘 돌아가고 있었기 때문입니다.

엔지니어링
CI/CD
데스크톱
안전망 없이 서른 개 언어를 출시하다
Steven
Steven6 분 소요

안전망 없이 서른 개 언어를 출시하다

GeekBye v1.8.3은 react-i18next를 배선하고 앱 전체를 30개 언어로 번역했습니다. 라이브러리를 붙이는 일은 판에 박힌 작업이었죠. 흥미로운 부분은 빠져 있던 것입니다: 키 정합성 검사도, 문자열 추출기도 없었기에 — 하드코딩된 영어가 새어 나갔고, 덴마크어 오타가 출시됐으며, 변경 로그는 이 앱이 28개인지 29개인지 30개 언어를 말하는지 자기들끼리도 합의하지 못했습니다.

엔지니어링
i18n
데스크톱