Steven
Steven8 мин. чтения

Два режима отказа накладки, пропускающей клики

Окно 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 всё равно продолжать пересылать движения игнорирующему окну, чтобы оно могло снова взвестись. Без этого накладка ушла бы в сквозной режим один раз и никогда бы не вернулась. Хук к тому же обращается через границу 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 мёртв; заклинь в другую — и мёртв весь твой рабочий стол.

Три вещи, которым нас научили эти релизы

  1. Накладка, пропускающая клики, — двусторонний контракт, и обе стороны отказывают по-разному. Слишком прозрачна — и твои собственные элементы управления мертвы; слишком хватка — и ты проглатываешь ввод других приложений. Большинство багов накладки — просто один из этих двух, и назвать, на какой ты стороне, говорит тебе, где искать.
  2. Самый высокий уровень always-on-top — не приз. Сидеть выше всего значит сидеть выше системных диалогов, которые нужно, чтобы пользователь увидел. Проектируй под момент, когда твоя накладка должна вежливо отступить — и сделай возврат наверх обратимым в finally, потому что наполовину сделанная уступка хуже, чем не уступать вовсе.
  3. Вычитание бьёт хитроумие. Победившим фиксом v1.8.14 стало удаление вызовов focus(), которые накладке никогда не были нужны, найденное лишь после недели строительства и отката кастомной обработки перетаскивания. Когда фича всё воюет с платформой, первый вопрос — можешь ли ты убрать то, что ведёт эту войну.

Про предыдущую главу истории v1 — релизы в CI, дважды (v1.8.4); а про всю дугу — анатомию доведения софта до совершенства.

Похожие статьи

От OCR к пикселям, не теряя запасной путь
Steven
Steven7 мин. чтения

От OCR к пикселям, не теряя запасной путь

GeekBye перестал читать твои скриншоты через OCR и начал отправлять само изображение зрячим моделям — и это лёгкая часть. Поучительная часть — оптимизация, которая пропускала OCR целиком для пользователей со зрячей моделью, тихо ломала запасной путь к моделям без зрения и её пришлось откатить до версии, где страховочная сетка бежит параллельно и не стоит ничего.

Инженерия
AI
Десктоп
Релизы в CI, дважды
Steven
Steven7 мин. чтения

Релизы в CI, дважды

GeekBye v1.8.4 перенёс сборки релизов в CI для macOS и Windows. Чего changelog не говорит — что мы уже пробовали это однажды, удалили месяц спустя из-за стоимости раннеров и заставили прижиться только со второго раза, потому что к тому моменту скрипт релиза уже работал вручную.

Инженерия
CI/CD
Десктоп
Выпуск тридцати языков без страховочной сетки
Steven
Steven7 мин. чтения

Выпуск тридцати языков без страховочной сетки

GeekBye v1.8.3 подключил react-i18next и перевёл всё приложение на 30 языков. Часть с библиотекой была рутиной. Интересно то, чего не хватало: ни проверки паритета ключей, ни экстрактора строк — так что зашитый в код английский просочился, в продакшн уехала датская опечатка, а changelog не мог договориться, на скольких языках говорит приложение — 28, 29 или 30.

Инженерия
i18n
Десктоп