Steven
Steven11 phút đọc

Hai Chế Độ Hỏng Của Một Lớp Phủ Cho Click Xuyên Qua

Cửa sổ của GeekBye nổi lên trên mọi thứ và để các cú click của bạn xuyên qua nó — trừ những chỗ nó có nút. Đó là một hợp đồng hai mặt, và v1.8.5 cùng v1.8.14 chính là dáng vẻ của lúc mỗi mặt vỡ: một bản phát hành lớp phủ nuốt mất một dialog hệ thống, một bản khác nó cướp các phím gõ của bạn. Cách sửa thắng cuộc cho vấn đề thứ hai là xóa code.

Kỹ thuật
Electron
Desktop
Bản phát hành GeekBye
Hai Chế Độ Hỏng Của Một Lớp Phủ Cho Click Xuyên Qua

Cửa sổ chính của GeekBye không phải một cửa sổ bình thường. Nó không viền, trong suốt, và luôn nằm trên cùng — một tấm kính nổi lên trên bất cứ thứ gì bạn đang làm, với vài control vẽ trên nó. Phần kỹ thuật thú vị không phải là nó nổi; mà là chuột của bạn được cho là đi thẳng xuyên qua tấm kính ở mọi nơi trừ các control. Click một cái nút trên lớp phủ và lớp phủ nhận cú click. Click vào khoảng trống cạnh nó và cú click rơi xuống ứng dụng họp của bạn ở bên dưới, cứ như thể lớp phủ chẳng hề có ở đó.

Đó là một hợp đồng có hai mặt: nhận input đúng ngay chỗ có UI, và hoàn toàn vô hình với input ở mọi nơi khác. Cả hai mặt phải đứng vững ở mọi khoảnh khắc, và mọi bug ở phần này của ứng dụng đều là sự vi phạm một trong hai mặt. Hai bản phát hành, cách nhau hai tháng, là minh họa sạch sẽ nhất có thể — vì mỗi bản làm vỡ một mặt khác nhau.

Hợp đồng, theo cơ chế

Phần hiện thực là một hook, useMousePassthrough, và nó nhỏ. Cửa sổ vốn click-xuyên-qua theo mặc định. Một listener mousemove toàn cục hỏi một câu ở mỗi lần di chuyển: con trỏ có đang ở trên một control không? Các control được gắn thẻ bằng một thuộc tính data-ui-region, nên phép kiểm tra đúng theo nghĩa đen là target.closest('[data-ui-region]'). Trên một control, nó gọi setIgnoreMouseEvents(false) và cửa sổ bắt các cú click. Trên khoảng không trong suốt, nó gọi setIgnoreMouseEvents(true, { forward: true }) và các cú click rơi xuyên qua.

Cái forward: true đó là chi tiết chịu lực. Một khi một cửa sổ đang phớt lờ các sự kiện chuột, bình thường nó sẽ ngừng nhận chúng hoàn toàn — kể cả các sự kiện mousemove mà hook cần để nhận ra con trỏ đang quay trở lại trên một control. forward: true bảo Electron cứ tiếp tục chuyển tiếp các chuyển động tới cửa sổ đang phớt lờ ấy dù sao đi nữa, để nó có thể tự nạp đạn lại. Không có nó, lớp phủ sẽ thành click-xuyên-qua một lần rồi không bao giờ quay lại. Hook cũng chỉ gọi qua ranh giới IPC ở các lần chuyển trạng thái — con trỏ băng qua ranh giới giữa UI và kính — chứ không phải ở mỗi pixel di chuyển. Hãy nhớ tối ưu ấy; nó có một cái lỗ, và v1.8.5 rơi vào đó.

v1.8.5: khi mặt trong suốt vỡ

Mặt trong suốt của hợp đồng là "vô hình với input ở mọi nơi không có UI." v1.8.5 là dáng vẻ của lúc lớp phủ hiện diện quá mức — khi nó bắt hoặc chặn input lẽ ra nó phải cho đi qua. Ngòi nổ là các dialog cấp quyền của macOS, và có hai thất bại khác biệt xếp chồng lên nhau.

Lớp phủ nằm vật lý ở trên dialog. Một cửa sổ luôn-trên-cùng có một cấp, và lớp phủ của GeekBye ngồi ở cấp cao nhất mà macOS cung cấp — screen-saver. Cấp đó ở trên gần như mọi thứ, kể cả các dialog cấp quyền Microphone và Screen Recording của chính hệ thống. Nên trong lúc onboarding, đúng vào khoảnh khắc macOS xin quyền, dialog có thể được vẽ bên dưới lớp phủ và trở nên vô hình. Cách sửa, setPermissionMode, chỉ dành cho macOS và làm một điều phản trực giác: nó hạ lớp phủ xuống cấp floating trong suốt thời gian có một yêu cầu cấp quyền, rồi khôi phục nó sau đó. Và việc khôi phục nó cần một mẹo cụ thể, được ghi lại ngay trong commit: macOS "không phải lúc nào cũng nâng lại một cửa sổ về mặt hình ảnh khi đặt cùng một cấp mà nó vốn đã ở đó," nên code cho cửa sổ bước qua một cấp trung gian — floating → status → screen-saver — để buộc window server tính lại thứ tự xếp chồng. Bạn không đặt thẳng cái cấp bạn muốn; bạn đi bộ tới nó.

Lớp phủ đang ăn mất các cú click của dialog. Onboarding vẽ một backdrop tối phủ kín cửa sổ, và hook passthrough xử lý nó như một trường hợp đặc biệt: trong khi cái backdrop đó đang dựng lên, nó ép cả cửa sổ bắt click để wizard tương tác được đầy đủ. Nhưng "bắt mọi cú click" lại chính xác là sai khi một dialog hệ thống đang cố gom lấy một cú. Cách sửa lột bỏ thuộc tính đánh dấu của backdrop trong lúc có yêu cầu cấp quyền, nên hook rớt ra khỏi chế độ bắt và các nút của dialog nhận được các cú click của chúng. Lớp phủ vẫn giữ các control của riêng nó tương tác được; nó chỉ ngừng giả vờ rằng khoảng kính trống là một cái nút.

Cái lỗ trong tối ưu. Hãy nhớ rằng hook chỉ hành động ở các lần chuyển. Khi cửa sổ rời chế độ bắt, nó chạy lại phép kiểm tra hover — nhưng nếu con trỏ của bạn đã sẵn nằm trên một control vào khoảnh khắc đó, câu trả lời cho "chúng ta có đang ở trên UI không?" vốn đã là "có," nên không có lần chuyển nào kích hoạt, và click-xuyên-qua chưa bao giờ bị tắt. Cái control trông sống động mà thật ra đã chết. Cách sửa (1b6eb20, trích) ngừng tin vào lần chuyển: "when exiting onboarding mode, directly evaluate whether cursor is over a UI region and make the correct IPC call." Khi bạn đổi chế độ, bạn không chờ lần di chuyển kế tiếp mách cho bạn con trỏ ở đâu — bạn nhìn.

Và làm cho sự nhường tháo gỡ lại được. Rủi ro tinh vi trong tất cả chuyện này là lớp phủ giờ đang tạm thời từ bỏ z-order và việc bắt click của nó, và phải có cái gì đó trả lại chúng. Nếu lệnh khôi phục ném lỗi — một cú nấc IPC, một cửa sổ bị tháo dỡ giữa chừng yêu cầu — ứng dụng có thể bị bỏ mặc với isRequestingPermission kẹt ở true và một backdrop tối che khuất toàn màn hình của bạn, vĩnh viễn. Nên đường thoát được bọc lại để việc reset trạng thái "luôn được thực thi," cộng thêm một safety timeout nhiều phút và một bước dọn dẹp khi cửa sổ bị hủy. Quy tắc rơi ra từ đó: mỗi lần một lớp phủ nhường input, việc thôi nhường phải nằm trong một finally, chứ không phải trên đường suôn sẻ.

v1.8.14: khi mặt bắt input vỡ

Mặt còn lại của hợp đồng là "nhận input ở chỗ có UI." v1.8.14 là dáng vẻ của lúc lớp phủ quá hay chụp giật — khi tương tác với nó khiến bạn mất input focus của ứng dụng bạn thực sự quan tâm.

Triệu chứng trong changelog là cướp focus, và đây là phiên bản trung thực, vì lịch sử git dạy được nhiều hơn ghi chú phát hành. Các handler phím tắt của lớp phủ — bật/tắt hiển thị bằng Cmd+B, mở cửa sổ chat, cuộn nó — mỗi cái trước kia đều gọi mainWindow.focus(), và trên macOS thì app.focus({ steal: true }), để chắc rằng lớp phủ nằm trước nhất khi bạn kích hoạt nó. Vấn đề là focus vào lớp phủ làm bất hoạt bất cứ thứ gì đang nằm trước nhất trước đó — cuộc họp của bạn, editor của bạn — nên một phím gõ nhắm cho chúng có thể rơi vào hư không, hoặc rơi sai chỗ. Lớp phủ đang chụp lấy một thứ mà thật ra nó không cần.

Phần cuối đó chính là toàn bộ bài học, và đội ngũ tìm ra nó theo cách khó nhọc. Nhánh về sau trở thành v1.8.14 ban đầu cố giải quyết riêng chuyện kéo — vì việc kéo có phiên bản đẹp đẽ của riêng nó về cái bug này: khi bạn kéo một lớp phủ toàn màn hình, cửa sổ dịch chuyển bên dưới một con trỏ đứng yên, nên mô hình hover tưởng rằng con trỏ đã "rời khỏi" control và lật sang click-xuyên-qua giữa lúc kéo, làm rớt cú kéo. Nên họ thêm một dragLock. Rồi họ thử làm cả cửa sổ non-focusable — điều đó phá vỡ việc gõ và việc kéo, nên họ khôi phục khả năng focus, rồi thu hẹp tính non-focusable chỉ còn cho các ô nhập text, rồi giật cái toggle ra. Họ dựng một custom drag IPC để thay cho drag gốc của Electron. Họ thêm blur-khi-focus-bất-ngờ. Cả một tuần như thế.

Rồi họ xóa gần hết tất cả. Commit được ship đã revert custom drag, focusable toggle, và blur handler, và giữ lại đúng một ý tưởng: ngừng gọi focus(). Sự nhận ra, viết vào một comment trong code, là lớp phủ chưa bao giờ cần focus của OS ngay từ đầu — "IPC messages (webContents.send) work without window focus." Lớp phủ lái renderer của chính nó trực tiếp; focus là thiệt hại ngoài dự tính thuần túy. Cách sửa ship được không phải bản viết lại custom-drag thông minh dựng trong một ngày. Nó là việc trừ đi những lệnh focus lẽ ra không nên có ở đó. (Đáng nói thẳng: changelog ghi "while dragging it," nhưng thứ thực sự được ship là loại bỏ việc cướp focus khỏi các handler phím tắt bàn phím — bộ máy chuyên cho việc kéo đã được mày mò rồi revert trong cùng tuần.)

Ván cược, được làm cho cụ thể

Nếu bạn muốn bằng chứng rằng hợp đồng này không phải chuyện sách vở, hãy nhìn điều xảy ra vài tháng sau khi lớp phủ mắc kẹt ở mặt sai của nó. Trong một bản phát hành muộn hơn nhiều, tiến trình renderer crash trong khi lớp phủ đang ở chế độ bắt — setIgnoreMouseEvents(false), cửa sổ chụp lấy mọi cú click — và với không có một UI còn sống nào để giải phóng nó, cái cửa sổ vô hình phủ kín cả desktop cứ nằm ì đó, ăn mọi cú click trên máy người dùng cho tới khi họ force-quit. Một cửa sổ trong suốt kẹt ở chế độ bắt không phải một cái nút chết; nó là một cái bẫy click rộng khắp cả desktop. (Cái đó có chẩn đoán riêng của nó — xem khi trợ lý ghi chú AI của bạn ngừng ghi giữa cuộc họp, bài ghép nó với một bug rất khác trong cùng bản phát hành.) Đó là lời phát biểu sắc bén nhất về toàn bộ vấn đề: làm cái toggle kẹt theo một hướng thì UI của bạn chết; kẹt theo hướng kia thì cả desktop của bạn chết.

Ba điều những bản phát hành này dạy chúng tôi

  1. Một lớp phủ cho click xuyên qua là một hợp đồng hai mặt, và hai mặt hỏng theo cách khác nhau. Quá trong suốt thì các control của chính bạn chết; quá hay chụp giật thì bạn nuốt mất input của ứng dụng khác. Hầu hết bug lớp phủ chỉ là một trong hai cái đó, và gọi tên mình đang ở mặt nào sẽ mách cho bạn phải nhìn vào đâu.
  2. Cấp always-on-top cao nhất không phải một phần thưởng. Ngồi trên mọi thứ nghĩa là ngồi trên các dialog hệ thống bạn cần người dùng nhìn thấy. Hãy thiết kế cho lúc lớp phủ của bạn nên lịch sự bước xuống một bước — và làm cho việc bước lại lên tháo gỡ được trong một finally, vì một sự nhường hoàn thành nửa vời còn tệ hơn không bao giờ nhường.
  3. Phép trừ thắng sự khôn khéo. Cách sửa thắng cuộc của v1.8.14 là xóa những lệnh focus() mà lớp phủ chưa bao giờ cần, chỉ tìm ra sau một tuần dựng rồi revert việc xử lý custom drag. Khi một tính năng cứ đánh nhau mãi với nền tảng, câu hỏi đầu tiên phải hỏi là liệu bạn có thể gỡ bỏ chính cái thứ đang đánh nhau đó không.

Về chương trước trong câu chuyện v1, xem Đưa Bản Phát Hành Vào CI, Hai Lần (v1.8.4); và cho toàn bộ vòng cung, xem giải phẫu việc xuất xưởng phần mềm đến độ hoàn hảo.

Bài Viết Liên Quan

Từ OCR Sang Pixel Mà Không Đánh Mất Fallback
Steven
Steven9 phút đọc

Từ OCR Sang Pixel Mà Không Đánh Mất Fallback

GeekBye ngừng đọc các screenshot của bạn bằng OCR và bắt đầu gửi hình ảnh thật tới các vision model — đó là phần dễ. Phần đáng học là tối ưu đã bỏ qua OCR hoàn toàn cho người dùng vision, lặng lẽ làm hỏng cái fallback sang các model không-vision, và phải bị đưa trở lại một phiên bản nơi tấm lưới an toàn chạy song song và chẳng tốn gì cả.

Kỹ thuật
AI
Desktop
Đưa Bản Phát Hành Vào CI, Hai Lần
Steven
Steven10 phút đọc

Đưa Bản Phát Hành Vào CI, Hai Lần

GeekBye v1.8.4 chuyển các bản build phát hành cho macOS và Windows vào CI. Điều changelog không nói là chúng tôi đã thử việc này một lần trước đó, xóa nó đi một tháng sau vì chi phí runner, và chỉ khiến nó trụ được ở lần thứ hai — bởi tới khi ấy release script đã chạy được bằng tay rồi.

Kỹ thuật
CI/CD
Desktop
Ship Ba Mươi Ngôn Ngữ Mà Không Có Lưới An Toàn
Steven
Steven10 phút đọc

Ship Ba Mươi Ngôn Ngữ Mà Không Có Lưới An Toàn

GeekBye v1.8.3 đấu nối react-i18next và dịch toàn bộ ứng dụng sang 30 ngôn ngữ. Phần thư viện chỉ là việc thường ngày. Phần thú vị nằm ở những gì còn thiếu: không có kiểm tra tính đồng nhất của key và không có công cụ trích xuất chuỗi — nên tiếng Anh hardcode lọt lưới, một lỗi chính tả tiếng Đan Mạch bị ship, và cái changelog thì không thống nhất nổi rằng ứng dụng nói 28, 29, hay 30 ngôn ngữ.

Kỹ thuật
i18n
Desktop