From 986edd79695de64beb2ea9bdf96a144604b1dd50 Mon Sep 17 00:00:00 2001 From: jenken827 Date: Sat, 12 Sep 2026 11:46:59 +0800 Subject: [PATCH 1/2] fix(desktop): correct WebView2 pointer capability report on Windows touchscreen devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Windows machines with a touchscreen (e.g. Surface), Chromium's device enumeration registers only the touch digitizer, so CSS media queries report (hover: none) / (pointer: coarse) even while a mouse is in use — and (any-hover: hover) / (any-pointer: fine) are false as well, so no CSS-side query can recover. Tailwind v4 compiles every hover/group-hover utility into @media (hover: hover), which left all hover styling in the app dead on such devices, including the reader tab close button. At startup, enumerate pointer devices via the Raw Input API; when a mouse or precision touchpad is present, append blink-settings overrides through WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS so the WebView reports a fine, hover-capable pointer. Touch capability is untouched (maxTouchPoints stays intact) and touch-only devices keep the current behavior. --- packages/app/src-tauri/src/lib.rs | 4 + packages/app/src-tauri/src/pointer_caps.rs | 122 +++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 packages/app/src-tauri/src/pointer_caps.rs diff --git a/packages/app/src-tauri/src/lib.rs b/packages/app/src-tauri/src/lib.rs index 382c6a415..fd497824c 100644 --- a/packages/app/src-tauri/src/lib.rs +++ b/packages/app/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod db; +mod pointer_caps; mod readany_cli; mod storage; mod sync; @@ -10,6 +11,9 @@ use vector::VectorDBState; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + // Must happen before the first WebView2 environment is created. + pointer_caps::apply_webview_pointer_capabilities(); + tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { if let Some(window) = app.get_webview_window("main") { diff --git a/packages/app/src-tauri/src/pointer_caps.rs b/packages/app/src-tauri/src/pointer_caps.rs new file mode 100644 index 000000000..3bcc0434d --- /dev/null +++ b/packages/app/src-tauri/src/pointer_caps.rs @@ -0,0 +1,122 @@ +//! WebView2 pointer-capability correction for Windows touchscreen machines. +//! +//! Chromium's device enumeration on some Windows touchscreen devices (e.g. +//! Surface) registers only the touch digitizer, so CSS media queries report +//! `(hover: none)` and `(pointer: coarse)` even while a mouse is in use — +//! which keeps every Tailwind `hover:` / `group-hover:` utility (including +//! the tab close button) permanently disabled. The same applies to +//! `(any-hover)` / `(any-pointer)`, so no CSS-side query can recover. +//! +//! When a fine pointer (mouse or precision touchpad) is present we override +//! the renderer's report via blink-settings so the media queries match the +//! hardware. Must run before the first WebView2 environment is created. + +const BLINK_SETTINGS_ARGS: &str = + "--blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4"; + +#[cfg(target_os = "windows")] +pub fn apply_webview_pointer_capabilities() { + if !has_fine_pointer() { + return; + } + + const KEY: &str = "WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS"; + let mut value = std::env::var(KEY).unwrap_or_default(); + if !value.is_empty() { + value.push(' '); + } + value.push_str(BLINK_SETTINGS_ARGS); + std::env::set_var(KEY, value); +} + +#[cfg(not(target_os = "windows"))] +pub fn apply_webview_pointer_capabilities() {} + +/// True when Windows reports a mouse or a precision touchpad. +/// +/// Touchscreen digitizers deliberately do NOT count: Chromium treats the +/// primary pointer as touch there, which is exactly the report we correct. +#[cfg(target_os = "windows")] +fn has_fine_pointer() -> bool { + use std::alloc::{alloc, dealloc, Layout}; + + // Minimal FFI for user32 raw-input enumeration; avoids a new dependency. + #[link(name = "user32")] + extern "system" { + fn GetRawInputDeviceList( + list: *mut RawInputDeviceList, + count: *mut u32, + size: u32, + ) -> u32; + fn GetRawInputDeviceInfoW( + device: isize, + command: u32, + data: *mut u8, + size: *mut u32, + ) -> u32; + } + + #[repr(C)] + #[derive(Clone, Copy)] + struct RawInputDeviceList { + h_device: isize, + dw_type: u32, + } + + const RIM_TYPEMOUSE: u32 = 0; + const RIM_TYPEHID: u32 = 2; + const RIDI_DEVICEINFO: u32 = 0x2000_0007; + // HID usage-page / usage pairs from HID_USAGE_PAGE_DIGITIZER. + const USAGE_PAGE_DIGITIZER: u16 = 0x000D; + const USAGE_TOUCH_PAD: u16 = 0x0005; + + unsafe { + let mut count: u32 = 0; + if GetRawInputDeviceList(std::ptr::null_mut(), &mut count, std::mem::size_of::() as u32) + == u32::MAX + { + return false; + } + if count == 0 { + return false; + } + let layout = Layout::array::(count as usize).expect("device list layout"); + let list = alloc(layout) as *mut RawInputDeviceList; + if list.is_null() { + return false; + } + let fetched = GetRawInputDeviceList(list, &mut count, std::mem::size_of::() as u32); + + let mut fine = false; + if fetched != u32::MAX { + for i in 0..fetched as usize { + let device = *list.add(i); + if device.dw_type == RIM_TYPEMOUSE { + fine = true; + break; + } + if device.dw_type != RIM_TYPEHID { + continue; + } + // RID_DEVICE_INFO: cbSize@0, dwType@4, then the HID union + // member: vendorId@8, productId@10, versionNumber@12, + // usagePage@14, usage@16 (all USHORT). + let mut info = [0u8; 32]; + let mut info_size = info.len() as u32; + if GetRawInputDeviceInfoW(device.h_device, RIDI_DEVICEINFO, info.as_mut_ptr(), &mut info_size) + != u32::MAX + { + let usage_page = u16::from_ne_bytes([info[14], info[15]]); + let usage = u16::from_ne_bytes([info[16], info[17]]); + if usage_page == USAGE_PAGE_DIGITIZER && usage == USAGE_TOUCH_PAD { + fine = true; + break; + } + } + } + } + + dealloc(list as *mut u8, layout); + fine + } +} From e9f588c01b8e148074205701e3ec00d36f994eb6 Mon Sep 17 00:00:00 2001 From: jenken827 Date: Sat, 12 Sep 2026 11:47:13 +0800 Subject: [PATCH 2/2] fix(ui): always show close button on the active reader tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tab close button only appeared on hover, and hover-only UI is unusable when the webview reports no hover capability (see previous commit) — with several books open there was no way to close a specific tab. Show the close button permanently on the active tab, keep the hover-reveal behavior for inactive tabs, and label the button with a localized tooltip (tabs.close) in all supported languages. --- packages/app/src/components/layout/TabBar.tsx | 7 ++++++- packages/core/src/i18n/locales/en/common.json | 1 + packages/core/src/i18n/locales/es/common.json | 1 + packages/core/src/i18n/locales/fr/common.json | 1 + packages/core/src/i18n/locales/ja/common.json | 1 + packages/core/src/i18n/locales/ko/common.json | 1 + packages/core/src/i18n/locales/zh-TW/common.json | 1 + packages/core/src/i18n/locales/zh/common.json | 1 + 8 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/layout/TabBar.tsx b/packages/app/src/components/layout/TabBar.tsx index 234bbcc0d..12117515e 100644 --- a/packages/app/src/components/layout/TabBar.tsx +++ b/packages/app/src/components/layout/TabBar.tsx @@ -11,6 +11,7 @@ import { useReaderStore } from "@/stores/reader-store"; import { useSyncStore } from "@/stores/sync-store"; import { BookOpen, FilePenLine, Home, MessageSquare, NotebookPen, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; const TAB_ICONS: Record = { home: Home, @@ -131,6 +132,7 @@ function TabItem({ onClose: () => void; }) { const Icon = TAB_ICONS[tab.type] ?? BookOpen; + const { t } = useTranslation(); return (
{tab.title}