
Два режими відмови накладки, що пропускає кліки
Вікно 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); а про всю дугу — анатомію доставки програмного забезпечення до досконалості.