From 554be11aea647cadd797c798e6eddf9c0d070372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=9F=A5=E9=A3=9F=E5=88=86=E5=AD=90?= <54768887+wzlstudy@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:19:47 +0800 Subject: [PATCH] fix(desktop): macos quit flash, first click and press feedback --- .../__tests__/buttonPressFeedback.spec.ts | 43 +++++++++++++++++++ .../desktop/src/components/ui/button/index.ts | 12 +++--- src-tauri/src/commands/app_settings.rs | 9 ++++ src-tauri/src/lib.rs | 35 +++++++++++++-- src-tauri/tauri.conf.json | 3 +- 5 files changed, 91 insertions(+), 11 deletions(-) create mode 100644 apps/desktop/src/components/ui/button/__tests__/buttonPressFeedback.spec.ts diff --git a/apps/desktop/src/components/ui/button/__tests__/buttonPressFeedback.spec.ts b/apps/desktop/src/components/ui/button/__tests__/buttonPressFeedback.spec.ts new file mode 100644 index 0000000000..df3ab0740b --- /dev/null +++ b/apps/desktop/src/components/ui/button/__tests__/buttonPressFeedback.spec.ts @@ -0,0 +1,43 @@ +// Source-contract guard for shared button press feedback. + +// Overview: desktop click responsiveness depends on buttons giving immediate +// visual feedback while pressed. DOM `click` only fires on pointer-up, so a +// press with zero visual delta reads as "the click did nothing" and users +// retry or hold the button (~1s holds measured in click-probe sessions). +// +// Two guarantees keep that regression from re-appearing: +// 1. Every button variant styles an `active:` pressed state, so pressing +// always changes something on screen before pointer-up. +// 2. The pressed state must not move or scale the button (`scale-*` / +// `translate-*`). A moving hit target can land pointer-up outside the +// element it was pressed on, which genuinely drops the click. +// Keep these assertions updated alongside the variant class strings. + +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const buttonVariantsSource = readFileSync(new URL("../index.ts", import.meta.url), "utf8"); + +const VARIANTS = ["default", "outline", "secondary", "ghost", "destructive", "link"] as const; + +function variantClasses(variant: string): string { + const match = buttonVariantsSource.match(new RegExp(`${variant}:\\s*"([^"]+)"`)); + expect(match, `variant "${variant}" exists in buttonVariants`).toBeTruthy(); + return match![1]; +} + +describe("button press feedback guard", () => { + it.each(VARIANTS)("styles an active pressed state for the %s variant", (variant) => { + expect(variantClasses(variant)).toMatch(/(^|\s)active:/); + }); + + it.each(VARIANTS)("does not move the hit target while %s is pressed", (variant) => { + expect(variantClasses(variant)).not.toMatch(/active:(scale|translate|-translate)-/); + }); + + it("does not move the hit target from the shared base classes", () => { + const base = buttonVariantsSource.match(/cva\(\s*"([^"]+)"/); + expect(base).toBeTruthy(); + expect(base![1]).not.toMatch(/active:(scale|translate|-translate)-/); + }); +}); diff --git a/apps/desktop/src/components/ui/button/index.ts b/apps/desktop/src/components/ui/button/index.ts index b9d9ac2a50..04f666f8c2 100644 --- a/apps/desktop/src/components/ui/button/index.ts +++ b/apps/desktop/src/components/ui/button/index.ts @@ -8,12 +8,12 @@ export const buttonVariants = cva( { variants: { variant: { - default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", - outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground", - secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", - ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground", - destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30", - link: "text-primary underline-offset-4 hover:underline", + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80 [a]:active:bg-primary/90 active:bg-primary/90", + outline: "border-border bg-background hover:bg-muted hover:text-foreground active:bg-accent active:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground", + secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 active:bg-secondary aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: "hover:bg-muted hover:text-foreground active:bg-accent active:text-accent-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground", + destructive: "bg-destructive/10 hover:bg-destructive/20 active:bg-destructive/30 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30 dark:active:bg-destructive/40", + link: "text-primary underline-offset-4 hover:underline active:opacity-80", }, size: { default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", diff --git a/src-tauri/src/commands/app_settings.rs b/src-tauri/src/commands/app_settings.rs index 0e56288b16..fdd9a27974 100644 --- a/src-tauri/src/commands/app_settings.rs +++ b/src-tauri/src/commands/app_settings.rs @@ -86,6 +86,15 @@ pub async fn complete_app_close(app: AppHandle, window: Window, action: String) if let Some(state) = app.try_state::() { state.allow_next_exit(); } + #[cfg(target_os = "macos")] + { + // Take the whole app off-screen first so WKWebView teardown during + // process exit cannot flash the window red on macOS 12. `AppHandle::hide` + // is macOS-only, so this block is compiled per-platform. + let _ = window.hide(); + let _ = app.hide(); + tokio::time::sleep(std::time::Duration::from_millis(crate::EXIT_HIDE_GRACE_MS)).await; + } app.exit(0); } "hide" => { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 701e33864c..352d7b40ca 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -115,6 +115,18 @@ fn should_hide_window_on_close(target_os: &str) -> bool { matches!(target_os, "macos" | "windows") } +/// How long to keep the app off-screen after hiding it and before the process +/// exits, so WindowServer has removed the window before WKWebView teardown. +#[cfg(target_os = "macos")] +pub(crate) const EXIT_HIDE_GRACE_MS: u64 = 250; + +/// On macOS, tearing down WKWebView while the window is still on screen can +/// paint the window red for a frame before the process exits, so the app is +/// hidden first. Windows and Linux keep their existing exit behavior. +fn should_hide_window_before_exit(target_os: &str) -> bool { + target_os == "macos" +} + fn should_setup_desktop_tray(target_os: &str, show_tray_icon: bool, linux_appindicator_available: bool) -> bool { show_tray_icon && (matches!(target_os, "macos" | "windows") || (target_os == "linux" && linux_appindicator_available)) @@ -973,10 +985,10 @@ mod tests { linux_drm_render_devices_from_paths, linux_nvidia_driver_from_state, linux_pci_id_from_sysfs_value, linux_selected_drm_render_device, linux_uses_native_wayland, linux_webkit_environment_override, linux_webkit_rendering_workarounds, native_window_decorations_override, should_confirm_app_exit_request, - should_enable_single_instance, should_fallback_to_native_quit, should_hide_window_on_close, - should_setup_desktop_tray, should_show_main_window_after_setup, should_show_main_window_before_setup_tasks, - startup_data_dir_mode, tray_menu_labels_for_locale, uses_application_level_icon, LinuxDrmRenderDevice, - LinuxNvidiaDriver, + should_enable_single_instance, should_fallback_to_native_quit, should_hide_window_before_exit, + should_hide_window_on_close, should_setup_desktop_tray, should_show_main_window_after_setup, + should_show_main_window_before_setup_tasks, startup_data_dir_mode, tray_menu_labels_for_locale, + uses_application_level_icon, LinuxDrmRenderDevice, LinuxNvidiaDriver, }; use crate::data_dir::DataDirMode; use std::ffi::OsStr; @@ -1027,6 +1039,13 @@ mod tests { assert!(!should_hide_window_on_close("linux")); } + #[test] + fn hides_window_before_exit_only_on_macos() { + assert!(should_hide_window_before_exit("macos")); + assert!(!should_hide_window_before_exit("windows")); + assert!(!should_hide_window_before_exit("linux")); + } + #[test] fn sets_up_desktop_tray_for_windows_macos_and_linux() { assert!(should_setup_desktop_tray("windows", true, false)); @@ -2528,6 +2547,14 @@ pub fn run() { api.prevent_exit(); request_app_close(app_handle, "quit"); } else { + // Restart exits and the no-frontend native quit bypass + // `complete_app_close`, so hide the window here too; the + // shutdown below gives WindowServer time to remove it. + if should_hide_window_before_exit(std::env::consts::OS) { + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.hide(); + } + } tauri::async_runtime::block_on(async { if let Some(server) = app_handle.try_state::() { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index dac799387b..2dcea1ed88 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -25,7 +25,8 @@ "x": 16, "y": 18 }, - "hiddenTitle": true + "hiddenTitle": true, + "acceptFirstMouse": true } ], "security": {