Steven
Steven1 分で読める

クリックスルー・オーバーレイの二つの故障モード

GeekByeのウィンドウはすべての上に浮かび、ボタンのある場所を除いて、あなたのクリックをそのまま通り抜けさせます。それは二面的な契約であり、v1.8.5とv1.8.14は、その各面が壊れたときにどう見えるかを示します: 一方のリリースではオーバーレイがシステムダイアログを飲み込み、もう一方ではあなたのキー入力を奪いました。二つ目の勝利をもたらした修正は、コードを削除することでした。

エンジニアリング
Electron
デスクトップ
GeekByeリリース
クリックスルー・オーバーレイの二つの故障モード

GeekByeのメインウィンドウは、普通のウィンドウではありません。フレームレスで、透明で、常に最前面 — あなたが何をしていようとその上に浮かぶ一枚のガラス板で、いくつかのコントロールがその上に描かれています。面白いエンジニアリングは、それが浮いていることではありません。あなたのマウスが、コントロールを除いたあらゆる場所で_ガラスをまっすぐ通り抜ける_はずだ、ということです。オーバーレイ上のボタンをクリックすればオーバーレイがそのクリックを受け取ります。その隣の空きスペースをクリックすれば、まるでオーバーレイがそこに無いかのように、クリックは下にあるあなたの会議アプリに着地します。

それは二つの側面を持つ契約です: UIがある場所ではきっちり入力を受け取り、それ以外のあらゆる場所では入力に対して完全に透明であれ。 両方の側面が一瞬たりとも崩れてはならず、アプリのこの部分におけるあらゆるバグは、どちらか一方の側の違反です。二か月離れた二つのリリースは、これ以上ないほど鮮やかな実例です — なぜなら、それぞれが違う側を壊したからです。

契約を、仕組みとして

実装はuseMousePassthroughというフックで、小さなものです。ウィンドウは_デフォルトで_クリックスルーです。グローバルなmousemoveリスナーが、動きのたびに一つの問いを投げます: カーソルはコントロールの上にあるか? コントロールにはdata-ui-region属性のタグが付いているので、判定は文字どおりtarget.closest('[data-ui-region]')です。コントロールの上ならsetIgnoreMouseEvents(false)を呼び、ウィンドウがクリックを捕まえます。透明な空間の上ならsetIgnoreMouseEvents(true, { forward: true })を呼び、クリックは通り抜けます。

そのforward: trueこそが要となるディテールです。ウィンドウがいったんマウスイベントを無視し始めると、通常はそれらを完全に受け取らなくなります — フックがカーソルがコントロールの上に_戻ってくる_のに気づくために必要なmousemoveイベントも含めて。forward: trueはElectronに、それでも無視状態のウィンドウへ動きを転送し続けるよう伝えるので、ウィンドウは自分を再武装できます。それが無ければ、オーバーレイは一度クリックスルーになったきり二度と戻ってきません。フックはまた、状態の_遷移_のとき — カーソルがUIとガラスの境界を越えるとき — だけIPC境界を越えて呼び出し、動きの1ピクセルごとには呼びません。その最適化を覚えておいてください; それには穴があり、v1.8.5がそこに落ちました。

v1.8.5: 透明性の側が壊れるとき

契約の透明性の側とは、「UIが無いあらゆる場所で入力に対して透明であれ」です。v1.8.5は、オーバーレイが_出しゃばりすぎる_とき — 通してやるべきだった入力を捕まえたり塞いだりするとき — にどう見えるかを示します。引き金はmacOSの権限ダイアログで、二つのはっきり異なる故障が互いの上に積み重なっていました。

オーバーレイは物理的にダイアログの上にありました。 常に最前面のウィンドウには_レベル_があり、GeekByeのオーバーレイはmacOSが提供する最も高いレベル — screen-saver — に座っていました。それはシステム自身のMicrophoneとScreen Recordingの権限ダイアログを含め、ほとんどすべての上です。だからオンボーディング中、まさにmacOSが権限を求めたその瞬間に、ダイアログはオーバーレイの_下に_描かれて見えなくなり得たのです。修正であるsetPermissionModeはmacOS専用で、直感に反することをします: 権限リクエストのあいだオーバーレイをfloatingレベルへ_下げ_、そのあとで元に戻すのです。そして元に戻すには特定のトリックが必要で、それはコミットにそのまま記録されています: macOSは「以前と同じレベルを設定してもウィンドウを必ずしも視覚的に再浮上させない」ので、コードはウィンドウを中間のレベルを通して段階的に進めます — 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オーダーとクリック捕捉を手放しており、何かがそれらを_返して_やらねばならない、ということです。もし復元の呼び出しが例外を投げれば — 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を加えました。次に彼らはウィンドウ全体を非フォーカス可能にしようとしました — それはタイピングとドラッグを壊したので、フォーカス可能性を元に戻し、次に非フォーカス可能性をテキスト入力だけに絞り、それからそのトグルを引き剥がしました。彼らはElectronのネイティブドラッグを置き換えるカスタムドラッグIPCを作りました。予期しないフォーカス時のブラーを加えました。これが一週間。

それから彼らはそのほとんどすべてを削除しました。出荷されたコミットは、カスタムドラッグ、フォーカス可能トグル、ブラーハンドラーを差し戻し、ちょうど一つの発想だけを残しました: focus()を呼ぶのをやめる。 コードコメントに書き込まれたその気づきは、オーバーレイはそもそもOSのフォーカスなど必要としていなかった、というものです — 「IPC messages (webContents.send) work without window focus.」オーバーレイは自分自身のレンダラーを直接駆動します; フォーカスは純然たる巻き添え被害でした。出荷できた修正は、一日で作られた賢いカスタムドラッグの書き直しではありませんでした。そこに在るべきでなかったフォーカス呼び出しを引き算することでした。(はっきり言っておく価値があります: changelogは「while dragging it」と言っていますが、実際に出荷されたのはキーボードショートカットのハンドラーからフォーカス奪取を取り除くことでした — ドラッグ特有の仕掛けは同じ週に探求され、そして差し戻されたのです。)

賭け金を、文字どおりに

この契約が机上の空論ではない証拠が欲しいなら、数か月後にオーバーレイがその契約の_間違った_側で立ち往生したときに何が起きたかを見てください。ずっとあとのリリースで、オーバーレイが捕捉モード — setIgnoreMouseEvents(false)、ウィンドウがすべてのクリックを掴んでいる状態 — にあるあいだにレンダラープロセスがクラッシュしました。そして、それを解放してくれる生きたUIが無いので、その見えないデスクトップ全面のウィンドウはそこに居座り、ユーザーが強制終了するまで、そのマシン上のあらゆるクリックを食べ続けました。捕捉モードで立ち往生した透明なウィンドウは、死んだボタンではありません; それはデスクトップ全体を覆うクリックの罠です。(あれは独自の診断を受けました — AI議事録アプリが会議の途中で録音を止めるときを参照してください。そこでは同じリリースの中の、まったく別のバグとそれが組み合わされています。)それは問題全体の最も鋭い言明です: トグルを一方向に立ち往生させればあなたのUIが死ぬ; もう一方向に立ち往生させればあなたのデスクトップ全体が死ぬのです。

これらのリリースが教えてくれた三つのこと

  1. クリックスルー・オーバーレイは二面的な契約であり、両方の側は異なる形で失敗する。 透明すぎればあなた自身のコントロールが死ぬ; 奪いすぎれば他のアプリの入力を飲み込む。ほとんどのオーバーレイのバグはその二つのうちのただ一つで、自分がどちらの側にいるかを名指しすることが、どこを見ればいいかを教えてくれる。
  2. 常に最前面の最も高いレベルは、ご褒美ではない。 _すべて_の上に座るということは、ユーザーに見せる必要のあるシステムダイアログの上に座るということだ。オーバーレイがいつ礼儀正しく一歩下がるべきかを見据えて設計せよ — そして一歩上がり直すことをfinallyの中で巻き戻し可能にせよ。なぜなら、半分だけ完了した譲渡は、まったく譲らないことよりも悪いからだ。
  3. 引き算は小賢しさに勝つ。 勝利をもたらしたv1.8.14の修正は、オーバーレイが一度も必要としなかったfocus()呼び出しを削除することで、カスタムドラッグ処理を作っては差し戻す一週間のあとにやっと見つかった。ある機能がプラットフォームと戦い続けるとき、まず問うべきなのは、戦いをしているそのものを取り除けないか、ということだ。

v1の物語の前の章については、リリースをCIに載せる、二度(v1.8.4)を; そして弧の全体については、ソフトウェアを完璧に出荷することの解剖学をご覧ください。

関連記事

OCRからピクセルへ、フォールバックを失わずに
Steven
Steven1 分で読める

OCRからピクセルへ、フォールバックを失わずに

GeekByeはスクリーンショットをOCRで読むのをやめ、実際の画像をvision対応モデルへ送り始めました — そこは簡単な部分です。学ぶべきなのは、vision利用者に対してOCRを丸ごと省いた最適化のほうです。それはvision非対応モデルへのフォールバックを静かに壊し、セーフティネットが並行して走り何のコストもかからないバージョンへと差し戻さねばなりませんでした。

エンジニアリング
AI
デスクトップ
リリースをCIに載せる、二度
Steven
Steven1 分で読める

リリースをCIに載せる、二度

GeekBye v1.8.4はリリースビルドをmacOSとWindowsの両方でCIへ移しました。変更履歴が語らないのは、これを一度前にも試し、ランナーコストのせいで一か月後に削除し、二度目でようやく定着させた — という事実です。なぜなら、その頃にはリリーススクリプトがすでに手作業で機能していたからです。

エンジニアリング
CI/CD
デスクトップ
セーフティネットなしで30言語を出荷する
Steven
Steven1 分で読める

セーフティネットなしで30言語を出荷する

GeekBye v1.8.3は`react-i18next`を組み込み、アプリ全体を30言語へ翻訳しました。ライブラリの部分はお決まりの作業です。面白いのは欠けていたもののほうです: キーの整合性チェックも文字列抽出ツールもなかった — だからハードコードされた英語が漏れ出し、デンマーク語のタイポが出荷され、変更履歴はアプリが28か29か30のどの言語を話すのかで意見が一致しませんでした。

エンジニアリング
i18n
デスクトップ