diff --git a/CHANGELOG.md b/CHANGELOG.md index 96ac0c336..62c98b0a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Linux UI Unresponsive on Startup**: Fixed a bug where the window UI (including native title bar buttons) couldn't receive clicks on Linux until the user manually maximized and restored the window. Root causes: (1) Tauri webview did not acquire keyboard focus after `show()` on Linux, so the first click was consumed by X11/Wayland click-to-activate (Tauri #10746, wry #637); (2) GTK surface's input region failed to renegotiate on the `visible:false → show()` path under some WebKitGTK/compositor combinations, leaving the entire window unresponsive. Mitigations: set `WEBKIT_DISABLE_COMPOSITING_MODE=1` at startup, and added a new `linux_fix::nudge_main_window` helper that performs `set_focus` + a ±1px no-op resize ~200ms after show, equivalent to a visually invisible "maximize-and-restore". Wired into all window-re-show paths (normal startup, deeplink, single_instance, tray `show_main`, lightweight exit). +- **Linux Drag Region on Header**: Removed `data-tauri-drag-region` from the top header bar on Linux to avoid triggering `gtk_window_begin_move_drag` paths affected by Tauri #13440 under Wayland. macOS drag behavior is preserved. + --- ## [3.12.3] - 2026-03-24 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 34673168c..393c88346 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,6 +13,8 @@ mod gemini_config; mod gemini_mcp; mod init_status; mod lightweight; +#[cfg(target_os = "linux")] +mod linux_fix; mod mcp; mod openclaw_config; mod opencode_config; @@ -132,6 +134,10 @@ fn handle_deeplink_url( let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); + #[cfg(target_os = "linux")] + { + linux_fix::nudge_main_window(window.clone()); + } log::info!("✓ Window shown and focused"); } } @@ -229,6 +235,10 @@ pub fn run() { let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); + #[cfg(target_os = "linux")] + { + linux_fix::nudge_main_window(window.clone()); + } } })); } @@ -899,6 +909,14 @@ pub fn run() { // 正常启动模式:显示窗口 let _ = window.show(); log::info!("正常启动模式:主窗口已显示"); + + // Linux: 解决首次启动 UI 无响应问题(Tauri #10746 + wry #637)。 + // 启动时 webview 未获取焦点 + surface 尺寸协商失败,导致点击无效。 + // 这里做 set_focus + 伪 resize,等价于无视觉版本的"最大化-还原"。 + #[cfg(target_os = "linux")] + { + linux_fix::nudge_main_window(window.clone()); + } } } diff --git a/src-tauri/src/lightweight.rs b/src-tauri/src/lightweight.rs index c16af79ef..78e5c9174 100644 --- a/src-tauri/src/lightweight.rs +++ b/src-tauri/src/lightweight.rs @@ -36,6 +36,10 @@ pub fn exit_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> { let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); + #[cfg(target_os = "linux")] + { + crate::linux_fix::nudge_main_window(window.clone()); + } #[cfg(target_os = "windows")] { let _ = window.set_skip_taskbar(false); @@ -66,6 +70,10 @@ pub fn exit_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window("main") { let _ = window.set_focus(); + #[cfg(target_os = "linux")] + { + crate::linux_fix::nudge_main_window(window.clone()); + } } #[cfg(target_os = "windows")] diff --git a/src-tauri/src/linux_fix.rs b/src-tauri/src/linux_fix.rs new file mode 100644 index 000000000..35bace1a3 --- /dev/null +++ b/src-tauri/src/linux_fix.rs @@ -0,0 +1,121 @@ +//! Linux 专用的主窗口恢复补丁。 +//! +//! 解决 Tauri 2.x 在部分 Linux 发行版(尤其是 Wayland / 某些 WebKitGTK +//! 版本)上启动后 UI 无法响应点击的问题: +//! +//! - **失效模式 A**(Tauri #10746 / wry #637):webview 在 `show()` 后 +//! 没有获得 keyboard focus,导致首次点击被 X11/Wayland 用作 +//! click-to-activate 而非传给 webview。 +//! - **失效模式 B**:GTK surface 与 WebKitWebView 的 input region 尺寸 +//! 协商在 `visible:false` → `show()` 的路径上失败,整窗永远不响应 +//! 点击,只有重新 `size_allocate`(例如最大化-还原)才能恢复。 +//! +//! 本模块导出 [`nudge_main_window`],它通过「显式 set_focus + 无视觉 +//! 版本的 ±1px 伪 resize」精确模拟用户手动最大化再还原的 workaround, +//! 但肉眼无法察觉。所有"让主窗口出现在用户面前"的路径(正常启动、 +//! deeplink 唤起、single_instance 回调、托盘 show_main、lightweight +//! 退出)都应在现有 `set_focus()` 之后追加一次调用。 + +use std::time::Duration; + +use tauri::{PhysicalSize, WebviewWindow}; + +/// 在 webview realize 之后的延迟,等 GTK 主循环把 realize 事件处理完。 +/// 200ms 是社区经验值;太短 set_focus 仍会无效,太长会让首屏可交互 +/// 时间被肉眼感知到。 +const REALIZE_WAIT: Duration = Duration::from_millis(200); + +/// ±1px 伪 resize 两步之间的间隔,确保 GTK 先处理了第一次 +/// `size_allocate` 再收到第二次 resize。放宽到 100ms 是因为 Tao 在 Linux +/// 上的尺寸 API 是异步的(底层走 `gtk_window_resize` → 合成器 configure), +/// 太短会让合成器把两次连续 resize coalesce 成一次。 +const RESIZE_GAP: Duration = Duration::from_millis(100); + +/// 尺寸对账回读前的额外等待。200ms + 100ms + 500ms = 总共 ~800ms 后 +/// 校验窗口尺寸是否回到 original。这个时间足够所有合成器处理完 +/// resize 消息队列。 +const RECONCILE_WAIT: Duration = Duration::from_millis(500); + +/// 对主窗口执行 Linux 专用的「focus + surface 重激活」序列。 +/// +/// 调用是 fire-and-forget:内部 spawn 一个异步任务在 ~250ms 后完成。 +/// 调用线程立即返回,不阻塞 UI。 +pub(crate) fn nudge_main_window(window: WebviewWindow) { + // 第一次 set_focus:webview 可能还没 realize,这一次通常是无效的, + // 但成本极低(线程安全,内部 run_on_main_thread),顺手做掉。 + let _ = window.set_focus(); + + tauri::async_runtime::spawn(async move { + tokio::time::sleep(REALIZE_WAIT).await; + + // 第二次 set_focus:此时 webview realize 已完成,在绝大多数 + // 发行版上这一次会真的生效,消除失效模式 A。 + let _ = window.set_focus(); + + // 伪 resize:读取当前 inner_size,先加 1px 再还原。这会触发 + // GTK 的 size-allocate → WebKitWebViewBase::size_allocate → + // 重新 attach input surface,消除失效模式 B。 + // + // 使用 PhysicalSize 避免跨 DPI 的逻辑坐标漂移;saturating_add + // 防止极端尺寸溢出。 + match window.inner_size() { + Ok(original) => { + let bumped = PhysicalSize::new(original.width.saturating_add(1), original.height); + let _ = window.set_size(bumped); + tokio::time::sleep(RESIZE_GAP).await; + let _ = window.set_size(original); + log::info!("Linux: 已对主窗口执行 focus + surface 重激活"); + + // 尺寸对账回读:Tao Linux 的尺寸 API 是异步的,`set_size` 只是把 + // resize 请求送进 GTK 主循环队列,合成器可能会 coalesce 两次连续 + // 请求(尤其是第二次 `set_size(original)`),导致窗口永久停留在 + // width+1。这里等合成器处理完队列后读一次实际尺寸,发现 drift 就 + // 再补一次 `set_size(original)` 兜底。 + // + // 已知限制:tiling Wayland 合成器(sway/river/hyprland)会完全忽略 + // `set_size`,此时对账永远 drift=0(因为两次 set_size 都是 no-op), + // 看起来"没问题"但失效模式 B 其实没被修复;这是已知限制,需要用户 + // 侧用 GDK_BACKEND=x11 绕过,README 应该有说明。 + tokio::time::sleep(RECONCILE_WAIT).await; + match window.inner_size() { + Ok(after) => { + if after.width != original.width || after.height != original.height { + log::info!( + "Linux nudge 尺寸 drift: expected={}x{}, got={}x{},已补偿", + original.width, + original.height, + after.width, + after.height + ); + let _ = window.set_size(original); + // 最终校验:如果补偿后仍然不一致,记 warn 让用户/开发者 + // 知道对账失败。这时窗口会停在非预期尺寸(通常是 +1px), + // 属于极端兜底场景。 + if let Ok(final_size) = window.inner_size() { + if final_size.width != original.width + || final_size.height != original.height + { + log::warn!( + "Linux nudge 尺寸 drift 补偿后仍不一致: expected={}x{}, got={}x{}", + original.width, + original.height, + final_size.width, + final_size.height + ); + } + } + } + } + Err(e) => { + log::warn!("Linux nudge: 对账回读 inner_size 失败: {e}"); + } + } + } + Err(e) => { + // 极罕见的失败路径;只做了 set_focus 也比什么都不做强, + // 不要让 resize 失败把整个补丁吞掉。 + log::warn!("Linux nudge: 读取 inner_size 失败,跳过伪 resize: {e}"); + } + } + }); +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2d1f85285..9d81b848f 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -10,6 +10,12 @@ fn main() { if std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err() { std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); } + // 禁用 WebKitGTK 合成模式,规避 resize 时 webview 崩溃以及部分 Wayland + // 合成器下的 surface 协商问题(整窗 UI 点击无响应、必须最大化-还原才能恢复)。 + // 参考: https://github.com/tauri-apps/tauri/issues/9394 + if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").is_err() { + std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1"); + } } cc_switch_lib::run(); diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 9f9b06c58..31af81b42 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -424,6 +424,10 @@ pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) { let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); + #[cfg(target_os = "linux")] + { + crate::linux_fix::nudge_main_window(window.clone()); + } #[cfg(target_os = "macos")] { apply_tray_policy(app, true); diff --git a/src/App.tsx b/src/App.tsx index 750c5ba2f..63d6d05cc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -40,7 +40,12 @@ import { useLastValidValue } from "@/hooks/useLastValidValue"; import { extractErrorMessage } from "@/utils/errorUtils"; import { isTextEditableTarget } from "@/utils/domUtils"; import { cn } from "@/lib/utils"; -import { isWindows, isLinux } from "@/lib/platform"; +import { + isWindows, + isLinux, + DRAG_REGION_ATTR, + DRAG_REGION_STYLE, +} from "@/lib/platform"; import { AppSwitcher } from "@/components/AppSwitcher"; import { ProviderList } from "@/components/providers/ProviderList"; import { AddProviderDialog } from "@/components/providers/AddProviderDialog"; @@ -876,11 +881,13 @@ function App() { className="flex flex-col h-screen overflow-hidden bg-background text-foreground selection:bg-primary/30" style={{ overflowX: "hidden", paddingTop: CONTENT_TOP_OFFSET }} > -
+ {DRAG_BAR_HEIGHT > 0 && ( +
+ )} {showEnvBanner && envConflicts.length > 0 && (
= ({ className="fixed inset-0 z-[60] flex flex-col" style={{ backgroundColor: "hsl(var(--background))" }} > - {/* Drag region - match App.tsx */} -
+ {/* Drag region - match App.tsx. Linux 上 DRAG_BAR_HEIGHT=0, + 直接跳过整个元素;macOS 保留 28px 拖拽占位。 */} + {DRAG_BAR_HEIGHT > 0 && ( +
+ )} {/* Header - match App.tsx */}
= ({ >