
Два режима отказа накладки, пропускающей клики
Окно 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 всё равно продолжать пересылать движения игнорирующему окну, чтобы оно могло снова взвестись. Без этого накладка ушла бы в сквозной режим один раз и никогда бы не вернулась. Хук к тому же обращается через границу IPC только на переходах состояния — когда курсор пересекает границу между UI и стеклом — а не на каждом пикселе движения. Запомни эту оптимизацию; в ней есть дыра, и v1.8.5 в неё провалился.
v1.8.5: когда ломается сторона прозрачности
Сторона прозрачности контракта — это «будь невидимой для ввода везде, где нет UI». v1.8.5 показывает, как выглядит слишком присутствующая накладка — когда она перехватывает или блокирует ввод, который должна была пропустить. Спусковым крючком были диалоги разрешений macOS, и там были две отдельные поломки, наложенные одна на другую.
Накладка была физически над диалогом. У окна всегда-поверх есть уровень, и накладка GeekBye сидела на самом высоком, что предлагает macOS — screen-saver. Это выше почти всего, включая собственные диалоги разрешений системы Microphone и Screen Recording. Так что во время онбординга, ровно в тот момент, когда macOS просил разрешение, диалог мог отрендериться под накладкой и быть невидимым. Фикс, setPermissionMode, только для macOS и делает неинтуитивную вещь: он опускает накладку до уровня floating на время запроса разрешения, а потом восстанавливает её. И восстановление требовало особого трюка, задокументированного прямо в коммите: macOS «doesn't always visually re-raise a window when setting the same level it was previously at», так что код проводит окно через промежуточный уровень — 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-order и свой перехват кликов, а что-то должно вернуть их обратно. Если вызов восстановления бросит исключение — икота 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. Потом попробовали сделать всё окно нефокусируемым — что сломало ввод текста и перетаскивание, так что они восстановили фокусируемость, потом сузили нефокусируемость только до текстовых полей, потом выдрали этот переключатель вон. Они построили кастомный IPC перетаскивания, чтобы заменить нативное перетаскивание Electron. Они добавили размытие-при-неожиданном-фокусе. Неделя такого.
Потом они удалили почти всё это. Коммит, что отгрузился, откатил кастомное перетаскивание, переключатель фокусируемости и обработчик размытия и сохранил ровно одну идею: перестать вызывать focus(). Осознание, вписанное в комментарий в коде, в том, что накладке с самого начала никогда не был нужен фокус ОС — «IPC messages (webContents.send) work without window focus». Накладка управляет собственным рендерером напрямую; фокус был чистым сопутствующим уроном. Пригодным к отгрузке лекарством была не умная переписка кастомного перетаскивания, построенная за день. Это было вычитание вызовов фокуса, которых там быть не должно. (Стоит сказать прямо: changelog говорит «while dragging it», но то, что на самом деле отгрузилось, было удалением кражи фокуса из обработчиков горячих клавиш — машинерия, специфичная для перетаскивания, была исследована и откачена на той же неделе.)
Ставка, буквально
Если хочешь доказательства, что этот контракт не академический, посмотри, что случилось месяцы спустя, когда накладка застряла на его неправильной стороне. В куда более позднем релизе процесс рендерера рухнул, пока накладка была в режиме перехвата — setIgnoreMouseEvents(false), окно хватает все клики — и, без живого UI, чтобы его отпустить, невидимое окно на весь рабочий стол сидело там, съедая каждый клик на машине пользователя, пока он не сделал force-quit. Прозрачное окно, застрявшее в режиме перехвата, — не мёртвая кнопка; это ловушка для кликов на весь рабочий стол. (У того была своя диагностика — см. когда твой ИИ-конспектировщик перестаёт записывать посреди встречи, где он сопоставлен с совсем другим багом из того же релиза.) Это самая острая формулировка всей проблемы: заклинь переключатель в одну сторону — и твой UI мёртв; заклинь в другую — и мёртв весь твой рабочий стол.
Три вещи, которым нас научили эти релизы
- Накладка, пропускающая клики, — двусторонний контракт, и обе стороны отказывают по-разному. Слишком прозрачна — и твои собственные элементы управления мертвы; слишком хватка — и ты проглатываешь ввод других приложений. Большинство багов накладки — просто один из этих двух, и назвать, на какой ты стороне, говорит тебе, где искать.
- Самый высокий уровень always-on-top — не приз. Сидеть выше всего значит сидеть выше системных диалогов, которые нужно, чтобы пользователь увидел. Проектируй под момент, когда твоя накладка должна вежливо отступить — и сделай возврат наверх обратимым в
finally, потому что наполовину сделанная уступка хуже, чем не уступать вовсе. - Вычитание бьёт хитроумие. Победившим фиксом v1.8.14 стало удаление вызовов
focus(), которые накладке никогда не были нужны, найденное лишь после недели строительства и отката кастомной обработки перетаскивания. Когда фича всё воюет с платформой, первый вопрос — можешь ли ты убрать то, что ведёт эту войну.
Про предыдущую главу истории v1 — релизы в CI, дважды (v1.8.4); а про всю дугу — анатомию доведения софта до совершенства.