
I due modi di fallimento di un overlay click-through
La finestra di GeekBye galleggia sopra ogni cosa e lascia che i tuoi clic la attraversino — tranne dove ha dei pulsanti. È un contratto a due facce, e la v1.8.5 e la v1.8.14 sono ciò che si vede quando ciascuna faccia si rompe: una release dove l'overlay ha inghiottito una finestra di dialogo di sistema, un'altra dove ti ha rubato le battute sulla tastiera. La correzione vincente per la seconda è stata cancellare codice.
La finestra principale di GeekBye non è una finestra normale. È senza cornice, trasparente e sempre in primo piano — una lastra di vetro che galleggia su qualunque cosa tu stia facendo, con qualche controllo dipinto sopra. La parte interessante dell'ingegneria non è che galleggia; è che il tuo mouse dovrebbe attraversare il vetro da parte a parte ovunque tranne che sui controlli. Clicca un pulsante sull'overlay e l'overlay riceve il clic. Clicca lo spazio vuoto accanto e il clic atterra sulla tua app di riunione lì sotto, come se l'overlay non ci fosse affatto.
Questo è un contratto con due facce: ricevere input esattamente dove c'è UI, ed essere completamente invisibile all'input in tutto il resto. Entrambe le facce devono reggere in ogni istante, e ogni bug in questa parte dell'app è una violazione di una faccia o dell'altra. Due release, a due mesi di distanza, ne sono l'illustrazione più pulita possibile — perché ciascuna ha rotto una faccia diversa.
Il contratto, meccanicamente
L'implementazione è un hook, useMousePassthrough, ed è piccolo. La finestra è click-through per default. Un listener globale di mousemove pone una sola domanda a ogni movimento: il cursore è sopra un controllo? I controlli sono marcati con un attributo data-ui-region, quindi il test è letteralmente target.closest('[data-ui-region]'). Sopra un controllo, chiama setIgnoreMouseEvents(false) e la finestra cattura i clic. Sopra spazio trasparente, chiama setIgnoreMouseEvents(true, { forward: true }) e i clic cadono attraverso.
Quel forward: true è il dettaglio che regge tutto il peso. Una volta che una finestra ignora gli eventi del mouse, normalmente smette di riceverli del tutto — inclusi gli eventi mousemove di cui l'hook ha bisogno per accorgersi del cursore che torna sopra un controllo. forward: true dice a Electron di continuare a inoltrare comunque i movimenti alla finestra che li ignora, così che possa riarmarsi. Senza di esso, l'overlay passerebbe a click-through una volta e non tornerebbe mai. L'hook inoltre attraversa il confine IPC solo sulle transizioni di stato — il cursore che varca il confine tra UI e vetro — non a ogni pixel di movimento. Ricorda quell'ottimizzazione; ha un buco, e la v1.8.5 ci è caduta dentro.
v1.8.5: quando si rompe la faccia della trasparenza
La faccia della trasparenza del contratto è «essere invisibile all'input ovunque non ci sia UI». La v1.8.5 è ciò che si vede quando l'overlay è troppo presente — quando cattura o blocca input che avrebbe dovuto lasciar passare. L'innesco erano le finestre di dialogo dei permessi di macOS, e c'erano due guasti distinti impilati uno sull'altro.
L'overlay era fisicamente sopra la finestra di dialogo. Una finestra always-on-top ha un livello, e l'overlay di GeekBye stava al più alto che macOS offre — screen-saver. Quello è sopra quasi tutto, incluse le finestre di dialogo dei permessi di Microphone e Screen Recording del sistema stesso. Così durante l'onboarding, nel momento esatto in cui macOS chiedeva il permesso, la finestra di dialogo poteva renderizzarsi sotto l'overlay ed essere invisibile. La correzione, setPermissionMode, è solo per macOS e fa la cosa controintuitiva: abbassa l'overlay al livello floating per la durata di una richiesta di permesso, poi lo ripristina dopo. E ripristinarlo richiedeva un trucco specifico, documentato proprio nel commit: macOS «doesn't always visually re-raise a window when setting the same level it was previously at», così il codice fa passare la finestra attraverso un livello intermedio — floating → status → screen-saver — per costringere il server delle finestre a ricalcolare l'ordine di impilamento. Non imposti il livello che vuoi; ci arrivi camminando.
L'overlay si mangiava i clic della finestra di dialogo. L'onboarding dipinge uno sfondo scuro che copre l'intera finestra, e l'hook di passthrough lo tratta come caso speciale: finché quello sfondo è alzato, forza la cattura dei clic sull'intera finestra così che la procedura guidata sia pienamente interattiva. Ma «catturare ogni clic» è esattamente sbagliato quando una finestra di dialogo di sistema sta cercando di raccoglierne uno. La correzione toglie l'attributo marcatore dello sfondo durante una richiesta di permesso, così l'hook esce dalla modalità cattura e i pulsanti della finestra di dialogo ricevono i loro clic. L'overlay tiene interattivi i propri controlli; smette solo di fingere che il vetro vuoto sia un pulsante.
Il buco nell'ottimizzazione. Ricorda che l'hook agisce solo sulle transizioni. Quando la finestra usciva dalla modalità cattura, rilanciava il test di hover — ma se il tuo cursore era già posato su un controllo in quel momento, la risposta a «siamo sopra UI?» era già «sì», quindi nessuna transizione scattava, e il click-through non veniva mai disattivato. Il controllo sembrava vivo ed era morto. La correzione (1b6eb20, citata) smette di fidarsi della transizione: «when exiting onboarding mode, directly evaluate whether cursor is over a UI region and make the correct IPC call». Quando cambi modalità, non aspetti che il prossimo movimento ti dica dov'è il cursore — guardi.
E rendi la cessione reversibile. Il rischio sottile in tutto questo è che l'overlay ora rinuncia temporaneamente al suo z-order e alla sua cattura dei clic, e qualcosa deve restituirglieli. Se la chiamata di ripristino solleva un'eccezione — un singhiozzo di IPC, una finestra smontata a metà richiesta — l'app potrebbe restare incagliata con isRequestingPermission bloccato su true e uno sfondo scuro che nasconde tutto il tuo schermo, per sempre. Così il percorso di uscita è stato avvolto perché il reset di stato «venga sempre eseguito», più un timeout di sicurezza di diversi minuti e una pulizia alla distruzione della finestra. La regola che ne deriva: ogni volta che un overlay cede input, il de-cedere deve stare in un finally, non in un percorso felice.
v1.8.14: quando si rompe la faccia della cattura
L'altra faccia del contratto è «ricevere input dove c'è UI». La v1.8.14 è ciò che si vede quando l'overlay è troppo avido — quando interagire con esso ti costa il focus di input dell'app che ti importa davvero.
Il sintomo nel changelog è il furto di focus, ed ecco la versione onesta, perché la storia di git è più istruttiva della nota di rilascio. I gestori di scorciatoie dell'overlay — alternare la visibilità con Cmd+B, aprire la finestra di chat, scorrerla — chiamavano ciascuno mainWindow.focus(), e su macOS app.focus({ steal: true }), per assicurarsi che l'overlay fosse in primo piano quando lo attivavi. Il problema è che dare il focus all'overlay disattiva qualunque cosa fosse in primo piano prima — la tua riunione, il tuo editor — così una battuta destinata a loro poteva atterrare da nessuna parte, o nel posto sbagliato. L'overlay stava afferrando una cosa di cui in realtà non aveva bisogno.
Quest'ultima parte è tutta la lezione, e il team l'ha scoperta nel modo duro. Il branch che è diventato la v1.8.14 ha prima provato a risolvere il trascinamento nello specifico — perché il trascinamento ha la sua bella versione di questo bug: quando trascini un overlay a schermo intero, la finestra si muove sotto un cursore fermo, così il modello di hover crede che il puntatore abbia «lasciato» il controllo e passa a click-through a metà trascinamento, lasciando cadere il trascinamento. Così hanno aggiunto un dragLock. Poi hanno provato a rendere l'intera finestra non focalizzabile — il che rompeva la digitazione e il trascinamento, così hanno ripristinato la focalizzabilità, poi hanno ristretto la non-focalizzabilità ai soli campi di testo, e poi hanno strappato via il toggle. Hanno costruito una IPC di trascinamento personalizzata per rimpiazzare il trascinamento nativo di Electron. Hanno aggiunto un blur-al-focus-inatteso. Una settimana di questo.
Poi hanno cancellato quasi tutto. Il commit che è stato spedito ha revertito il trascinamento personalizzato, il toggle di focalizzabilità e il gestore di blur, e ha tenuto esattamente un'idea: smettere di chiamare focus(). La presa di coscienza, scritta in un commento di codice, è che l'overlay non ha mai avuto bisogno del focus del SO in primo luogo — «IPC messages (webContents.send) work without window focus». L'overlay guida il proprio renderer direttamente; il focus era puro danno collaterale. La correzione spedibile non è stata l'astuta riscrittura del trascinamento personalizzato costruita in un giorno. È stata sottrarre le chiamate a focus() che non avrebbero dovuto esserci. (Vale la pena dirlo chiaramente: il changelog dice «while dragging it», ma ciò che è stato davvero spedito è stato rimuovere il furto di focus dai gestori di scorciatoie da tastiera — la macchineria specifica del trascinamento è stata esplorata e revertita nella stessa settimana.)
La posta in gioco, resa letterale
Se vuoi la prova che questo contratto non è accademico, guarda cosa è successo mesi dopo quando l'overlay è rimasto bloccato sulla faccia sbagliata. In una release molto più tarda, il processo renderer è andato in crash mentre l'overlay era in modalità cattura — setIgnoreMouseEvents(false), la finestra che afferra tutti i clic — e senza una UI viva a rilasciarlo, la finestra invisibile che copriva l'intero desktop se ne stava lì a mangiare ogni clic sulla macchina dell'utente finché non ha forzato la chiusura. Una finestra trasparente bloccata in modalità cattura non è un pulsante morto; è una trappola per clic estesa a tutto il desktop. (Quello ha avuto la sua diagnosi a parte — vedi perché il tuo notetaker AI smette di registrare a metà riunione, che lo abbina a un bug molto diverso nella stessa release.) È la formulazione più affilata dell'intero problema: blocca il toggle da un lato e la tua UI è morta; bloccato dall'altro lato, e lo è tutto il tuo desktop.
Tre cose che queste release ci hanno insegnato
- Un overlay click-through è un contratto a due facce, e le due facce falliscono in modi diversi. Troppo trasparente e i tuoi stessi controlli muoiono; troppo avido e inghiotti l'input delle altre app. La maggior parte dei bug di overlay è solo uno di quei due, e nominare su quale faccia sei ti dice dove guardare.
- Il livello always-on-top più alto non è un premio. Stare sopra tutto significa stare sopra le finestre di dialogo di sistema che hai bisogno che l'utente veda. Progetta per quando il tuo overlay dovrebbe educatamente farsi da parte — e rendi il risalire reversibile in un
finally, perché una cessione portata a metà è peggio che non cedere mai. - La sottrazione batte l'ingegnosità. La correzione vincente della v1.8.14 è stata cancellare chiamate a
focus()di cui l'overlay non ha mai avuto bisogno, trovate solo dopo una settimana di costruire e revertire la gestione del trascinamento personalizzato. Quando una funzionalità continua a combattere con la piattaforma, la prima domanda da porsi è se puoi rimuovere la cosa che sta combattendo.
Per il capitolo precedente della storia v1, mettere la release nella CI, due volte (v1.8.4); e per tutto l'arco, l'anatomia del pubblicare software fino alla perfezione.