
클릭 통과 오버레이의 두 가지 실패 모드
GeekBye의 창은 모든 것 위에 떠 있으면서, 버튼이 있는 곳을 빼고는 당신의 클릭이 그것을 그대로 통과하게 둡니다. 그것은 양면 계약이고, v1.8.5와 v1.8.14는 각 면이 깨질 때 어떤 모습인지를 보여 줍니다: 한 릴리스에서는 오버레이가 시스템 대화상자를 삼켰고, 다른 하나에서는 당신의 키 입력을 훔쳤습니다. 두 번째를 이긴 해결책은 코드를 지우는 것이었습니다.
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가 죽고; 다른 방향으로 갇히게 하면 당신의 데스크톱 전체가 죽습니다.
이 릴리스들이 우리에게 가르쳐 준 세 가지
- 클릭 통과 오버레이는 양면 계약이고, 두 면은 서로 다르게 실패한다. 너무 투명하면 당신 자신의 컨트롤이 죽고; 너무 움켜쥐면 다른 앱들의 입력을 삼킨다. 대부분의 오버레이 버그는 그 둘 중 하나일 뿐이고, 자신이 어느 면에 있는지를 이름 붙이는 것이 어디를 봐야 할지 알려 준다.
- 항상 위 중 가장 높은 레벨은 상이 아니다. 모든 것 위에 앉는다는 건 사용자가 봐야 하는 시스템 대화상자 위에 앉는다는 뜻이다. 당신의 오버레이가 언제 정중히 한 걸음 물러나야 하는지를 내다보고 설계하라 — 그리고 다시 한 걸음 올라오는 것을
finally안에서 되감을 수 있게 만들어라. 절반만 완료된 양보는 아예 양보하지 않는 것보다 나쁘기 때문이다. - 뺄셈이 영리함을 이긴다. 이긴 v1.8.14의 해결책은 오버레이가 한 번도 필요로 하지 않은
focus()호출을 지우는 것이었고, 커스텀 드래그 처리를 만들고 되돌리는 일주일 뒤에야 발견됐다. 어떤 기능이 플랫폼과 계속 싸울 때, 가장 먼저 물어야 할 질문은 싸움을 하는 그 물건을 제거할 수 있느냐다.
v1 이야기의 이전 장은 릴리스를 CI에 넣다, 두 번(v1.8.4)을 보시고; 전체 궤적은 소프트웨어를 완벽하게 출시하는 것의 해부학을 보세요.