
Двата режима на отказ на click-through overlay
Прозорецът на GeekBye плава над всичко и оставя кликовете ти да минават през него — освен там, където има бутони. Това е двустранен договор, а v1.8.5 и v1.8.14 показват как изглежда, когато всяка от страните се счупи: едно издание, в което overlay-ът глътна системен диалог, и едно, в което открадна натисканията на клавишите ти. Печелившият fix за второто беше изтриване на код.
Основният прозорец на GeekBye не е нормален прозорец. Той е безрамков, прозрачен и винаги отгоре — лист стъкло, плаващ над каквото и да правиш, с няколко контроли, нарисувани върху него. Интересната инженерия не е в това, че плава; тя е в това, че мишката ти би трябвало да минава право през стъклото навсякъде, освен върху контролите. Кликваш бутон върху overlay-а и overlay-ът получава клика. Кликваш празното пространство до него и кликът каца върху приложението ти за срещи отдолу, сякаш overlay-ът изобщо не е там.
Това е договор с две страни: получавай вход точно там, където има UI, и бъди напълно невидим за вход навсякъде другаде. И двете страни трябва да издържат във всеки момент, а всеки bug в тази част на приложението е нарушение на едната или другата страна. Две издания, на два месеца едно от друго, са възможно най-чистата илюстрация — защото всяко от тях счупи различна страна.
Договорът, механично
Реализацията е hook, useMousePassthrough, и е малка. Прозорецът е click-through по подразбиране. Глобален mousemove слушател задава един въпрос при всяко движение: курсорът над контрола ли е? Контролите са маркирани с атрибут data-ui-region, така че тестът е буквално target.closest('[data-ui-region]'). Над контрола извиква setIgnoreMouseEvents(false) и прозорецът улавя кликовете. Над прозрачно пространство извиква setIgnoreMouseEvents(true, { forward: true }) и кликовете пропадат надолу.
Онова forward: true е носещата подробност. Щом веднъж прозорец игнорира събитията на мишката, той нормално изобщо престава да ги получава — включително mousemove събитията, от които hook-ът има нужда, за да забележи как курсорът се връща обратно над контрола. forward: true казва на Electron въпреки това да продължи да препраща движенията към игнориращия прозорец, за да може той да се пренасочи. Без него overlay-ът би станал click-through веднъж и никога не би се върнал. Освен това hook-ът извиква през IPC границата само при преходи на състоянието — курсорът пресича границата между UI и стъклото — а не при всеки пиксел движение. Запомни тази оптимизация; тя има дупка, а v1.8.5 падна в нея.
v1.8.5: когато се чупи страната на прозрачността
Страната на прозрачността от договора е „бъди невидим за вход навсякъде, където няма UI." v1.8.5 показва как изглежда, когато overlay-ът е прекалено натрапчив — когато улавя или блокира вход, който е трябвало да пусне да мине. Спусъкът бяха диалозите за разрешения на macOS, и имаше два отделни провала, натрупани един върху друг.
Overlay-ът беше физически над диалога. Един always-on-top прозорец има ниво, а overlay-ът на GeekBye стоеше на най-високото, което macOS предлага — screen-saver. То е над почти всичко, включително над собствените диалози за разрешения Microphone и Screen Recording на системата. Така че по време на onboarding, в точния момент, в който macOS искаше разрешение, диалогът можеше да се рендира под overlay-а и да е невидим. Fix-ът, setPermissionMode, е само за macOS и прави неинтуитивното: сваля overlay-а до ниво floating за времетраенето на заявката за разрешение, после го възстановява след това. А възстановяването му се нуждаеше от специфичен трик, документиран точно в комита: macOS "doesn't always visually re-raise a window when setting the same level it was previously at," така че кодът прекарва прозореца през междинно ниво — floating → status → screen-saver — за да принуди прозоречния сървър да преизчисли реда на подреждане. Не задаваш нивото, което искаш; стъпка по стъпка стигаш до него.
Overlay-ът изяждаше кликовете на диалога. Onboarding-ът рисува тъмен фон на целия прозорец, а passthrough hook-ът го третира като специален случай: докато този фон е вдигнат, той принуждава улавяне на кликовете върху целия прозорец, за да е съветникът напълно интерактивен. Но „улавяй всеки клик" е точно погрешно, когато системен диалог се опитва да събере един. Fix-ът маха маркерния атрибут на фона по време на заявка за разрешение, така че hook-ът излиза от режим на улавяне и бутоните на диалога получават кликовете си. Overlay-ът запазва собствените си контроли интерактивни; просто престава да се преструва, че празното стъкло е бутон.
Дупката в оптимизацията. Помни, че hook-ът действа само при преходи. Когато прозорецът напусна режима на улавяне, той изпълни отново hover теста — но ако в онзи момент курсорът ти вече стоеше върху контрола, отговорът на „над UI ли сме?" вече беше „да," така че не се задейства никакъв преход и click-through никога не се изключи. Контролът изглеждаше жив, а беше мъртъв. Fix-ът (1b6eb20, цитиран) престава да вярва на прехода: "when exiting onboarding mode, directly evaluate whether cursor is over a UI region and make the correct IPC call." Когато сменяш режими, не чакаш следващото движение да ти каже къде е курсорът — поглеждаш.
И направи отстъпването обратимо. Финият риск във всичко това е, че overlay-ът сега временно се отказва от своя z-order и от улавянето на кликове, и нещо трябва да му ги върне обратно. Ако извикването за възстановяване хвърли грешка — засечка в IPC, прозорец, разрушен по средата на заявката — приложението може да остане заклещено с isRequestingPermission, заседнало на true, и тъмен фон, скриващ целия ти екран, завинаги. Затова изходният път беше обвит така, че нулирането на състоянието „винаги се изпълнява," плюс предпазен таймаут от няколко минути и почистване при разрушаване на прозореца. Правилото, което следва оттук: всеки път, когато overlay отстъпва вход, връщането на отстъпеното трябва да е в finally, а не в щастливия път.
v1.8.14: когато се чупи страната на улавянето
Другата страна на договора е „получавай вход там, където има UI." v1.8.14 показва как изглежда, когато overlay-ът е прекалено жаден да грабне — когато взаимодействието с него ти струва входния фокус на приложението, което всъщност те интересува.
Симптомът в changelog-а е кражба на фокус, и ето честната версия, защото git историята е по-поучителна от бележката за издание. Handler-ите за преки пътища на overlay-а — превключи видимостта с Cmd+B, отвори прозореца за чат, скролирай го — всеки от тях извикваше mainWindow.focus(), а на macOS app.focus({ steal: true }), за да е сигурно, че overlay-ът е най-отпред, когато го задействаш. Проблемът е, че фокусирането на overlay-а деактивира каквото е било най-отпред преди това — твоята среща, твоя редактор — така че натискане на клавиш, предназначено за тях, можеше да кацне никъде или на грешно място. Overlay-ът грабваше нещо, от което всъщност не се нуждаеше.
Тази последна част е целият урок, и екипът го откри по трудния начин. Клонът, който стана v1.8.14, първо се опита да реши конкретно влаченето — защото влаченето има своя собствена красива версия на този bug: когато влачиш overlay на цял екран, прозорецът се движи под неподвижен курсор, така че hover моделът смята, че показалецът „напусна" контрола, и се превключва на click-through по средата на влаченето, изпускайки влаченето. Затова добавиха dragLock. После опитаха да направят целия прозорец нефокусируем — което счупи писането и влаченето, така че върнаха фокусируемостта, после стесниха нефокусируемостта само до текстовите полета, после изтръгнаха превключвателя. Построиха custom drag IPC, за да заменят родния drag на Electron. Добавиха blur-при-неочакван-фокус. Цяла седмица от това.
После изтриха почти всичко. Комитът, който беше издаден, върна custom drag-а, превключвателя за фокусируемост и blur handler-а, и запази точно една идея: спри да извикваш focus(). Прозрението, записано в коментар в кода, е че overlay-ът изобщо никога не се е нуждаел от фокус на OS на първо място — "IPC messages (webContents.send) work without window focus." Overlay-ът управлява собствения си renderer директно; фокусът беше чиста странична щета. Годният за издаване fix не беше умното пренаписване на custom drag, построено за един ден. Беше изваждане на извикванията на фокус, които не биваше да са там. (Струва си да се каже направо: changelog-ът казва "while dragging it," но това, което всъщност беше издадено, беше премахването на кражбата на фокус от handler-ите за преки пътища с клавиатура — специфичната за влаченето машинария беше изследвана и върната същата седмица.)
Залогът, направен буквален
Ако искаш доказателство, че този договор не е академичен, погледни какво се случи месеци по-късно, когато overlay-ът заседна на грешната му страна. В много по-късно издание процесът на renderer се срина, докато overlay-ът беше в режим на улавяне — setIgnoreMouseEvents(false), прозорец, грабващ всички кликове — и без жив UI, който да го освободи, невидимият прозорец с размер на целия десктоп стоеше там, изяждайки всеки клик на машината на потребителя, докато той не форсира изход. Прозрачен прозорец, заседнал в режим на улавяне, не е мъртъв бутон; той е капан за кликове с размер на целия десктоп. (Онзи получи собствена диагноза — виж когато твоят AI водещ на бележки спре да записва по средата на срещата, която го сдвоява с много различен bug от същото издание.) Това е най-острата формулировка на целия проблем: заклещи превключвателя по единия начин и твоят UI е мъртъв; заклещен по другия начин, и целият ти десктоп е.
Три неща, на които ни научиха тези издания
- Един click-through overlay е двустранен договор, и двете страни се провалят различно. Прекалено прозрачен и собствените ти контроли умират; прекалено жаден да грабне и глъщаш входа на други приложения. Повечето overlay bug-ове са просто едно от тези две, и назоваването на коя страна си ти казва къде да гледаш.
- Най-високото always-on-top ниво не е награда. Да стоиш над всичко означава да стоиш над системните диалози, които имаш нужда потребителят да види. Проектирай за момента, в който overlay-ът ти трябва учтиво да отстъпи — и направи връщането нагоре обратимо в
finally, защото наполовина завършено отстъпване е по-лошо от това никога да не отстъпваш. - Изваждането бие изобретателността. Печелившият fix на v1.8.14 беше изтриване на
focus()извиквания, от които overlay-ът никога не се е нуждаел, намерено едва след седмица строене и връщане на custom drag обработка. Когато една функция постоянно се бори с платформата, първият въпрос, който да зададеш, е дали можеш да премахнеш нещото, което се бори.
За предишната глава от историята на v1 — слагане на издаването в CI, два пъти (v1.8.4); а за цялата дъга — анатомията на доставянето на софтуер до съвършенство.