Steven
Steven9 Min. Lesezeit

Die zwei Fehlermodi eines Click-Through-Overlays

GeekByes Fenster schwebt über allem und lässt deine Klicks hindurchfallen — außer dort, wo es Buttons hat. Das ist ein zweiseitiger Vertrag, und v1.8.5 und v1.8.14 sind, wie es aussieht, wenn jede Seite bricht: ein Release, in dem das Overlay einen Systemdialog verschluckte, eines, in dem es deine Tastenanschläge stahl. Der siegreiche Fix für den zweiten war, Code zu löschen.

Technik
Electron
Desktop
GeekBye-Releases
Die zwei Fehlermodi eines Click-Through-Overlays

GeekByes Hauptfenster ist kein normales Fenster. Es ist rahmenlos, transparent und immer im Vordergrund — eine Glasscheibe, die über allem schwebt, was du gerade tust, mit ein paar darauf gemalten Bedienelementen. Das Interessante an der Technik ist nicht, dass es schwebt; es ist, dass deine Maus geradewegs durch das Glas hindurchgehen soll, überall außer auf den Bedienelementen. Klick einen Button auf dem Overlay, und das Overlay bekommt den Klick. Klick den leeren Raum daneben, und der Klick landet auf deiner Meeting-App darunter, als wäre das Overlay überhaupt nicht da.

Das ist ein Vertrag mit zwei Seiten: Input genau dort empfangen, wo UI ist, und überall sonst für Input vollkommen unsichtbar sein. Beide Seiten müssen in jedem Augenblick halten, und jeder Bug in diesem Teil der App ist eine Verletzung der einen oder der anderen Seite. Zwei Releases, zwei Monate auseinander, sind die sauberste mögliche Veranschaulichung — weil jedes eine andere Seite brach.

Der Vertrag, mechanisch

Die Implementierung ist ein Hook, useMousePassthrough, und er ist klein. Das Fenster ist standardmäßig click-through. Ein globaler mousemove-Listener stellt bei jeder Bewegung eine Frage: Ist der Cursor über einem Bedienelement? Bedienelemente sind mit einem data-ui-region-Attribut markiert, also lautet der Test buchstäblich target.closest('[data-ui-region]'). Über einem Bedienelement ruft er setIgnoreMouseEvents(false) auf, und das Fenster fängt Klicks ab. Über transparentem Raum ruft er setIgnoreMouseEvents(true, { forward: true }) auf, und Klicks fallen hindurch.

Dieses forward: true ist das tragende Detail. Sobald ein Fenster Mausereignisse ignoriert, hört es normalerweise ganz auf, sie zu empfangen — einschließlich der mousemove-Ereignisse, die der Hook braucht, um den Cursor zu bemerken, der zurück über ein Bedienelement kommt. forward: true sagt Electron, die Bewegungen trotzdem weiter an das ignorierende Fenster zu leiten, damit es sich neu scharfstellen kann. Ohne es würde das Overlay einmal click-through gehen und nie wiederkommen. Der Hook überquert die IPC-Grenze außerdem nur bei Zustands-Übergängen — wenn der Cursor die Grenze zwischen UI und Glas überschreitet — nicht bei jedem Pixel Bewegung. Merk dir diese Optimierung; sie hat ein Loch, und v1.8.5 fiel hinein.

v1.8.5: wenn die Transparenz-Seite bricht

Die Transparenz-Seite des Vertrags lautet „überall unsichtbar für Input sein, wo es keine UI gibt". v1.8.5 ist, wie es aussieht, wenn das Overlay zu präsent ist — wenn es Input abfängt oder blockiert, den es hätte durchlassen sollen. Der Auslöser waren macOS-Berechtigungsdialoge, und es gab zwei verschiedene, aufeinandergestapelte Fehler.

Das Overlay war physisch über dem Dialog. Ein Always-on-top-Fenster hat ein Level, und GeekByes Overlay saß auf dem höchsten, das macOS bietet — screen-saver. Das ist über fast allem, einschließlich der systemeigenen Microphone- und Screen-Recording-Berechtigungsdialoge. Also konnte während des Onboardings, im genauen Moment, in dem macOS um Erlaubnis bat, der Dialog unter dem Overlay rendern und unsichtbar sein. Der Fix, setPermissionMode, ist nur für macOS und tut das Unintuitive: Er senkt das Overlay für die Dauer einer Berechtigungsanfrage auf das floating-Level ab und stellt es danach wieder her. Und das Wiederherstellen brauchte einen bestimmten Trick, direkt im Commit dokumentiert: macOS „doesn't always visually re-raise a window when setting the same level it was previously at", also schrittet der Code das Fenster durch ein Zwischenlevel — floating → status → screen-saver —, um den Fensterserver zu zwingen, die Stapelreihenfolge neu zu berechnen. Du setzt nicht das Level, das du willst; du läufst zu ihm hin.

Das Overlay fraß die Klicks des Dialogs. Das Onboarding malt einen dunklen, das ganze Fenster füllenden Hintergrund, und der Passthrough-Hook behandelt ihn als Sonderfall: solange dieser Hintergrund oben ist, erzwingt er Klick-Erfassung auf dem ganzen Fenster, damit der Assistent voll interaktiv ist. Aber „jeden Klick abfangen" ist genau falsch, wenn ein Systemdialog gerade versucht, einen einzusammeln. Der Fix entfernt das Markierungsattribut des Hintergrunds während einer Berechtigungsanfrage, sodass der Hook aus dem Erfassungsmodus herausfällt und die Buttons des Dialogs ihre Klicks bekommen. Das Overlay hält seine eigenen Bedienelemente interaktiv; es hört nur auf, so zu tun, als wäre das leere Glas ein Button.

Das Loch in der Optimierung. Denk daran, dass der Hook nur bei Übergängen handelt. Als das Fenster den Erfassungsmodus verließ, führte es den Hover-Test erneut aus — aber wenn dein Cursor in diesem Moment bereits auf einem Bedienelement saß, war die Antwort auf „sind wir über UI?" schon „ja", also feuerte kein Übergang, und click-through wurde nie deaktiviert. Das Bedienelement sah lebendig aus und war tot. Der Fix (1b6eb20, zitiert) hört auf, dem Übergang zu vertrauen: „when exiting onboarding mode, directly evaluate whether cursor is over a UI region and make the correct IPC call". Wenn du den Modus wechselst, wartest du nicht auf die nächste Bewegung, die dir sagt, wo der Cursor ist — du schaust nach.

Und mach die Abtretung umkehrbar. Das subtile Risiko in all dem ist, dass das Overlay jetzt vorübergehend seine Z-Order und seine Klick-Erfassung aufgibt, und irgendetwas muss sie zurückgeben. Wenn der Wiederherstellungsaufruf eine Ausnahme wirft — ein IPC-Schluckauf, ein mitten in der Anfrage abgebautes Fenster — könnte die App mit isRequestingPermission festhängend auf true und einem dunklen Hintergrund, der deinen ganzen Bildschirm verdeckt, dauerhaft stranden. Also wurde der Ausstiegspfad so umhüllt, dass der Zustands-Reset „immer ausgeführt wird", plus ein mehrminütiges Sicherheits-Timeout und eine Bereinigung beim Zerstören des Fensters. Die Regel, die sich ergibt: jedes Mal, wenn ein Overlay Input abtritt, muss das Zurücknehmen der Abtretung in einem finally stehen, nicht auf einem glücklichen Pfad.

v1.8.14: wenn die Erfassungs-Seite bricht

Die andere Seite des Vertrags lautet „Input dort empfangen, wo es UI gibt". v1.8.14 ist, wie es aussieht, wenn das Overlay zu griffig ist — wenn die Interaktion mit ihm dich den Input-Fokus der App kostet, die dir eigentlich wichtig ist.

Das Symptom im Changelog ist Fokus-Diebstahl, und hier ist die ehrliche Version, denn die git-Historie ist lehrreicher als die Release-Notiz. Die Shortcut-Handler des Overlays — Sichtbarkeit auf Cmd+B umschalten, das Chatfenster öffnen, es scrollen — riefen jeweils mainWindow.focus() auf, und auf macOS app.focus({ steal: true }), um sicherzugehen, dass das Overlay im Vordergrund war, wenn du es auslöstest. Das Problem ist, dass dem Overlay den Fokus zu geben das deaktiviert, was vorher im Vordergrund war — dein Meeting, dein Editor — sodass ein Tastenanschlag, der für sie gedacht war, nirgends landen konnte, oder am falschen Ort. Das Overlay griff nach einer Sache, die es eigentlich nicht brauchte.

Dieser letzte Teil ist die ganze Lektion, und das Team fand es auf die harte Tour heraus. Der Branch, der zur v1.8.14 wurde, versuchte zuerst, gezielt das Ziehen zu lösen — denn das Ziehen hat seine eigene schöne Version dieses Bugs: wenn du ein Vollbild-Overlay ziehst, bewegt sich das Fenster unter einem stillstehenden Cursor, sodass das Hover-Modell denkt, der Zeiger habe das Bedienelement „verlassen", und mitten im Ziehen auf click-through umschlägt, was das Ziehen fallenlässt. Also fügten sie ein dragLock hinzu. Dann versuchten sie, das ganze Fenster nicht-fokussierbar zu machen — was Tippen und Ziehen brach, also stellten sie die Fokussierbarkeit wieder her, engten dann die Nicht-Fokussierbarkeit auf nur Texteingaben ein und rissen dann den Toggle heraus. Sie bauten eine Custom-Drag-IPC, um Electrons natives Ziehen zu ersetzen. Sie fügten ein Blur-bei-unerwartetem-Fokus hinzu. Eine Woche davon.

Dann löschten sie fast alles davon. Der ausgelieferte Commit revertierte das Custom-Drag, den Fokussierbar-Toggle und den Blur-Handler und behielt genau eine Idee: aufhören, focus() aufzurufen. Die Erkenntnis, in einen Code-Kommentar geschrieben, ist, dass das Overlay von vornherein nie OS-Fokus brauchte — „IPC messages (webContents.send) work without window focus". Das Overlay steuert seinen eigenen Renderer direkt; der Fokus war reiner Kollateralschaden. Der auslieferbare Fix war nicht die clevere, an einem Tag gebaute Custom-Drag-Neufassung. Er war das Subtrahieren der focus()-Aufrufe, die nie hätten da sein sollen. (Klar gesagt: Das Changelog sagt „while dragging it", aber was tatsächlich ausgeliefert wurde, war das Entfernen des Fokus-Diebstahls aus den Tastenkürzel-Handlern — die drag-spezifische Maschinerie wurde in derselben Woche erkundet und revertiert.)

Was auf dem Spiel steht, wörtlich gemacht

Wenn du einen Beweis willst, dass dieser Vertrag nicht akademisch ist, sieh dir an, was Monate später passierte, als das Overlay auf der falschen Seite davon feststeckte. In einem viel späteren Release stürzte der Renderer-Prozess ab, während das Overlay im Erfassungsmodus war — setIgnoreMouseEvents(false), das Fenster schnappt sich alle Klicks — und ohne lebendige UI, die es freigäbe, saß das unsichtbare Fenster über dem ganzen Desktop da und fraß jeden Klick auf der Maschine des Nutzers, bis er es zwangsbeendete. Ein transparentes, im Erfassungsmodus feststeckendes Fenster ist kein toter Button; es ist eine desktopweite Klick-Falle. (Das bekam seine eigene Diagnose — siehe warum dein KI-Notetaker mitten im Meeting die Aufnahme stoppt, das es mit einem ganz anderen Bug im selben Release paart.) Es ist die schärfste Formulierung des ganzen Problems: bring den Toggle in die eine Richtung zum Feststecken, und deine UI ist tot; in die andere feststeckend, und dein ganzer Desktop ist es.

Drei Dinge, die uns diese Releases lehrten

  1. Ein Click-Through-Overlay ist ein zweiseitiger Vertrag, und beide Seiten scheitern unterschiedlich. Zu transparent, und deine eigenen Bedienelemente werden tot; zu griffig, und du verschluckst den Input anderer Apps. Die meisten Overlay-Bugs sind nur einer dieser zwei, und zu benennen, auf welcher Seite du bist, sagt dir, wo du suchen musst.
  2. Das höchste Always-on-top-Level ist kein Preis. Über allem zu sitzen heißt, über den Systemdialogen zu sitzen, die der Nutzer sehen muss. Entwirf für den Moment, in dem dein Overlay höflich zurücktreten sollte — und mach das Wieder-Hochkommen in einem finally umkehrbar, denn eine halb abgeschlossene Abtretung ist schlimmer als nie abzutreten.
  3. Subtraktion schlägt Cleverness. Der siegreiche v1.8.14-Fix war das Löschen von focus()-Aufrufen, die das Overlay nie brauchte, erst nach einer Woche des Bauens und Revertierens von Custom-Drag-Handling gefunden. Wenn ein Feature ständig mit der Plattform kämpft, ist die erste Frage, ob du das Ding entfernen kannst, das den Kampf führt.

Für das vorige Kapitel der v1-Geschichte, das Release zweimal in die CI packen (v1.8.4); und für den ganzen Bogen, die Anatomie, Software bis zur Perfektion auszuliefern.