
Οι δύο τρόποι αστοχίας ενός click-through overlay
Το παράθυρο του GeekBye επιπλέει πάνω από τα πάντα και αφήνει τα κλικ σου να περνούν μέσα από αυτό — εκτός από εκεί όπου έχει κουμπιά. Αυτό είναι ένα αμφίπλευρο συμβόλαιο, και το v1.8.5 και το v1.8.14 δείχνουν πώς μοιάζει όταν σπάει η κάθε πλευρά: μια έκδοση όπου το overlay κατάπιε έναν διάλογο συστήματος, μία όπου έκλεψε τα πλήκτρα σου. Η νικήτρια διόρθωση για τη δεύτερη ήταν η διαγραφή κώδικα.
Το κύριο παράθυρο του GeekBye δεν είναι ένα κανονικό παράθυρο. Είναι χωρίς πλαίσιο, διαφανές και πάντα στην κορυφή — ένα τζάμι που επιπλέει πάνω από ό,τι κάνεις, με μερικά χειριστήρια ζωγραφισμένα πάνω του. Το ενδιαφέρον κομμάτι μηχανικής δεν είναι ότι επιπλέει· είναι ότι το ποντίκι σου υποτίθεται πως περνά ίσια μέσα από το τζάμι παντού εκτός από τα χειριστήρια. Κάνε κλικ σε ένα κουμπί του overlay και το overlay παίρνει το κλικ. Κάνε κλικ στον κενό χώρο δίπλα του και το κλικ προσγειώνεται στην εφαρμογή σύσκεψής σου από κάτω, σαν το overlay να μην ήταν καθόλου εκεί.
Αυτό είναι ένα συμβόλαιο με δύο πλευρές: λάβε είσοδο ακριβώς εκεί όπου υπάρχει UI, και γίνε εντελώς αόρατο στην είσοδο παντού αλλού. Και οι δύο πλευρές πρέπει να κρατούν σε κάθε στιγμή, και κάθε bug σε αυτό το κομμάτι της εφαρμογής είναι μια παραβίαση της μίας ή της άλλης πλευράς. Δύο εκδόσεις, με δύο μήνες απόσταση, είναι η πιο καθαρή δυνατή απεικόνιση — επειδή η καθεμία έσπασε διαφορετική πλευρά.
Το συμβόλαιο, μηχανικά
Η υλοποίηση είναι ένα hook, το useMousePassthrough, και είναι μικρό. Το παράθυρο είναι click-through από προεπιλογή. Ένας καθολικός mousemove listener κάνει μία ερώτηση σε κάθε κίνηση: είναι ο δρομέας πάνω από ένα χειριστήριο; Τα χειριστήρια είναι σημειωμένα με ένα attribute 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 και τζαμιού — όχι σε κάθε pixel κίνησης. Θυμήσου αυτή τη βελτιστοποίηση· έχει μια τρύπα, και το 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 και να είναι αόρατος. Η διόρθωση, το setPermissionMode, είναι μόνο για macOS και κάνει το μη-διαισθητικό: κατεβάζει το overlay στο επίπεδο floating για τη διάρκεια ενός αιτήματος άδειας, μετά το επαναφέρει έπειτα. Και η επαναφορά του χρειαζόταν ένα συγκεκριμένο κόλπο, τεκμηριωμένο ακριβώς μέσα στο commit: το macOS "doesn't always visually re-raise a window when setting the same level it was previously at," οπότε ο κώδικας περνά το παράθυρο μέσα από ένα ενδιάμεσο επίπεδο — floating → status → screen-saver — για να αναγκάσει τον window server να ξαναϋπολογίσει τη σειρά στοίβαξης. Δεν ορίζεις το επίπεδο που θέλεις· φτάνεις σε αυτό βήμα βήμα.
Το overlay έτρωγε τα κλικ του διαλόγου. Το onboarding ζωγραφίζει ένα σκοτεινό φόντο σε όλο το παράθυρο, και το passthrough hook το χειρίζεται ως ειδική περίπτωση: όσο αυτό το φόντο είναι σηκωμένο, επιβάλλει σύλληψη κλικ σε όλο το παράθυρο ώστε ο οδηγός να είναι πλήρως διαδραστικός. Αλλά το «σύλλαβε κάθε κλικ» είναι ακριβώς λάθος όταν ένας διάλογος συστήματος προσπαθεί να μαζέψει ένα. Η διόρθωση αφαιρεί το attribute-δείκτη του φόντου κατά τη διάρκεια ενός αιτήματος άδειας, ώστε το hook να βγαίνει από τη λειτουργία σύλληψης και τα κουμπιά του διαλόγου να παίρνουν τα κλικ τους. Το overlay κρατά τα δικά του χειριστήρια διαδραστικά· απλώς σταματά να προσποιείται πως το κενό τζάμι είναι κουμπί.
Η τρύπα στη βελτιστοποίηση. Θυμήσου ότι το hook δρα μόνο στις μεταβάσεις. Όταν το παράθυρο βγήκε από τη λειτουργία σύλληψης, ξανάτρεξε το hover τεστ — αλλά αν ο δρομέας σου καθόταν ήδη πάνω σε ένα χειριστήριο εκείνη τη στιγμή, η απάντηση στο «είμαστε πάνω από UI;» ήταν ήδη «ναι,» οπότε δεν πυροδοτήθηκε καμία μετάβαση, και το click-through δεν απενεργοποιήθηκε ποτέ. Το χειριστήριο έμοιαζε ζωντανό και ήταν νεκρό. Η διόρθωση (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 ιστορικό είναι πιο διδακτικό από τη σημείωση έκδοσης. Οι handlers συντομεύσεων του overlay — εναλλαγή ορατότητας με Cmd+B, άνοιγμα του παραθύρου chat, κύλισή του — καθένας τους καλούσε mainWindow.focus(), και στο macOS app.focus({ steal: true }), για να βεβαιωθεί ότι το overlay ήταν μπροστά-μπροστά όταν το πυροδοτούσες. Το πρόβλημα είναι ότι η εστίαση στο overlay απενεργοποιεί ό,τι ήταν μπροστά πριν — τη σύσκεψή σου, τον editor σου — έτσι ένα πάτημα πλήκτρου που προοριζόταν γι' αυτά μπορούσε να προσγειωθεί πουθενά, ή σε λάθος μέρος. Το overlay άρπαζε ένα πράγμα που στην πραγματικότητα δεν χρειαζόταν.
Αυτό το τελευταίο κομμάτι είναι όλο το μάθημα, και η ομάδα το βρήκε με τον δύσκολο τρόπο. Το branch που έγινε v1.8.14 πρώτα προσπάθησε να λύσει συγκεκριμένα το σύρσιμο — επειδή το σύρσιμο έχει τη δική του όμορφη εκδοχή αυτού του bug: όταν σέρνεις ένα overlay πλήρους οθόνης, το παράθυρο κινείται κάτω από έναν ακίνητο δρομέα, οπότε το μοντέλο hover νομίζει ότι ο δείκτης «έφυγε» από το χειριστήριο και γυρίζει σε click-through στη μέση του συρσίματος, ρίχνοντας το σύρσιμο. Έτσι πρόσθεσαν ένα dragLock. Μετά δοκίμασαν να κάνουν όλο το παράθυρο μη-εστιάσιμο — πράγμα που έσπασε την πληκτρολόγηση και το σύρσιμο, οπότε επανέφεραν την εστιασιμότητα, μετά περιόρισαν τη μη-εστιασιμότητα μόνο στα πεδία κειμένου, μετά ξήλωσαν τον διακόπτη. Έφτιαξαν ένα custom drag IPC για να αντικαταστήσουν το native drag του Electron. Πρόσθεσαν blur-σε-απροσδόκητη-εστίαση. Μια εβδομάδα από αυτό.
Μετά διέγραψαν σχεδόν όλα. Το commit που εκδόθηκε ανακάλεσε το custom drag, τον διακόπτη εστιασιμότητας και τον handler blur, και κράτησε ακριβώς μία ιδέα: σταμάτα να καλείς focus(). Η συνειδητοποίηση, γραμμένη μέσα σε ένα σχόλιο κώδικα, είναι ότι το overlay δεν χρειάστηκε ποτέ εστίαση από το OS εξαρχής — "IPC messages (webContents.send) work without window focus." Το overlay οδηγεί τον δικό του renderer απευθείας· η εστίαση ήταν καθαρή παράπλευρη ζημιά. Η αποστείλιμη διόρθωση δεν ήταν η έξυπνη επανεγγραφή custom drag φτιαγμένη σε μία μέρα. Ήταν η αφαίρεση των κλήσεων εστίασης που δεν έπρεπε να είναι εκεί. (Αξίζει να ειπωθεί ξεκάθαρα: το changelog λέει "while dragging it," αλλά αυτό που πραγματικά εκδόθηκε ήταν η αφαίρεση της κλοπής εστίασης από τους handlers των συντομεύσεων πληκτρολογίου — η μηχανική ειδικά για το σύρσιμο εξερευνήθηκε και ανακλήθηκε την ίδια εβδομάδα.)
Το διακύβευμα, κυριολεκτικά
Αν θέλεις απόδειξη ότι αυτό το συμβόλαιο δεν είναι ακαδημαϊκό, δες τι έγινε μήνες αργότερα όταν το overlay κόλλησε στη λάθος πλευρά του. Σε μια πολύ μεταγενέστερη έκδοση, η διεργασία renderer κατέρρευσε ενώ το overlay ήταν σε λειτουργία σύλληψης — setIgnoreMouseEvents(false), παράθυρο που άρπαζε όλα τα κλικ — και χωρίς ζωντανό UI να το απελευθερώσει, το αόρατο παράθυρο μεγέθους ολόκληρου του desktop καθόταν εκεί τρώγοντας κάθε κλικ στο μηχάνημα του χρήστη μέχρι που εκείνος έκανε force-quit. Ένα διαφανές παράθυρο κολλημένο σε λειτουργία σύλληψης δεν είναι ένα νεκρό κουμπί· είναι μια παγίδα κλικ σε όλο το εύρος του desktop. (Εκείνο πήρε τη δική του διάγνωση — δες όταν ο AI σημειωματογράφος σου σταματά να καταγράφει στη μέση της σύσκεψης, που τον ζευγαρώνει με ένα πολύ διαφορετικό bug στην ίδια έκδοση.) Είναι η πιο κοφτερή διατύπωση όλου του προβλήματος: κόλλησε τον διακόπτη προς τη μία μεριά και το UI σου είναι νεκρό· κολλημένος προς την άλλη, και όλο σου το desktop είναι.
Τρία πράγματα που μας δίδαξαν αυτές οι εκδόσεις
- Ένα click-through overlay είναι ένα αμφίπλευρο συμβόλαιο, και οι δύο πλευρές αστοχούν διαφορετικά. Υπερβολικά διαφανές και τα δικά σου χειριστήρια νεκρώνουν· υπερβολικά αρπακτικό και καταπίνεις την είσοδο άλλων εφαρμογών. Τα περισσότερα overlay bugs είναι απλώς ένα από αυτά τα δύο, και το να ονομάσεις σε ποια πλευρά βρίσκεσαι σου λέει πού να κοιτάξεις.
- Το υψηλότερο always-on-top επίπεδο δεν είναι έπαθλο. Το να κάθεσαι πάνω από τα πάντα σημαίνει να κάθεσαι πάνω από τους διαλόγους συστήματος που χρειάζεσαι να δει ο χρήστης. Σχεδίασε για το πότε το overlay σου πρέπει ευγενικά να κάνει στην άκρη — και κάνε την επάνοδο αναστρέψιμη σε ένα
finally, επειδή μια μισοτελειωμένη παραχώρηση είναι χειρότερη από το να μην παραχωρείς ποτέ. - Η αφαίρεση νικά την εξυπνάδα. Η νικήτρια διόρθωση του v1.8.14 ήταν η διαγραφή κλήσεων
focus()που το overlay ποτέ δεν χρειάστηκε, που βρέθηκε μόνο μετά από μια εβδομάδα χτισίματος και ανάκλησης custom drag διαχείρισης. Όταν ένα χαρακτηριστικό συνεχίζει να παλεύει με την πλατφόρμα, η πρώτη ερώτηση που πρέπει να κάνεις είναι αν μπορείς να αφαιρέσεις το πράγμα που παλεύει.
Για το προηγούμενο κεφάλαιο της ιστορίας του v1, βάζοντας την έκδοση στο CI, δύο φορές (v1.8.4)· και για ολόκληρη την πορεία, η ανατομία της παράδοσης λογισμικού στην τελειότητα.