fix(desktop): macos quit flash, first click and press feedback

This commit is contained in:
知食分子
2026-09-01 01:19:47 +08:00
committed by GitHub
parent 23f511de22
commit 554be11aea
5 changed files with 91 additions and 11 deletions
@@ -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)-/);
});
});
@@ -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",
+9
View File
@@ -86,6 +86,15 @@ pub async fn complete_app_close(app: AppHandle, window: Window, action: String)
if let Some(state) = app.try_state::<CloseBehaviorState>() {
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" => {
+31 -4
View File
@@ -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::<commands::redis_pubsub_server::PubSubServerState>()
{
+2 -1
View File
@@ -25,7 +25,8 @@
"x": 16,
"y": 18
},
"hiddenTitle": true
"hiddenTitle": true,
"acceptFirstMouse": true
}
],
"security": {