Merge pull request #531 from aeoform/feat/linux-custom-titlebar

fix: Linux Wayland 改用应用级自定义标题栏,绕过 GTK CSD 点击无响应问题
This commit is contained in:
Chris233 authored and GitHub committed 2026-05-26 10:51:36 +08:00
commit 4723863d5a
4 files changed
+213 -13

No files matched your search

+41 -2
View File
@@ -204,8 +204,47 @@ pub fn run() {
let suppress_show = !force_show && coordinator.prefs().get().start_minimized;
if suppress_show {
log::info!("[main] start_minimized=true → 跳过初始 show,等用户点托盘");
} else if let Err(e) = main.show() {
log::warn!("[main] initial show failed: {e}");
} else {
#[cfg(target_os = "linux")]
{
// Workaround for Linux Wayland WebKitGTK compositing:
// `visible:false` → `show()` can leave the webview surface
// without a valid input region. The ±1px nudge forces
// GTK size-allocate → input surface reattach.
// Ref: tauri#9394, cc-switch linux_fix.rs
let main_clone = main.clone();
let _ = main_clone.set_focus();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
let _ = main_clone.set_focus();
if let Ok(orig) = main_clone.inner_size() {
let bumped = tauri::PhysicalSize::new(
orig.width.saturating_add(1),
orig.height,
);
let _ = main_clone.set_size(bumped);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let _ = main_clone.set_size(orig);
log::info!("[main] Linux nudge: focus + surface reactivation done");
// Reconcile: compositor may have coalesced the two
// set_size calls, leaving the window at width+1.
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
if let Ok(after) = main_clone.inner_size() {
// Only correct the ±1px nudge artifact — if the
// compositor or user resized the window significantly
// during this window, don't clobber that change.
let dw = if after.width > orig.width { after.width - orig.width } else { orig.width - after.width };
let dh = if after.height > orig.height { after.height - orig.height } else { orig.height - after.height };
if dw <= 1 && dh <= 1 && (dw > 0 || dh > 0) {
let _ = main_clone.set_size(orig);
}
}
}
});
}
if let Err(e) = main.show() {
log::warn!("[main] initial show failed: {e}");
}
}
}
+15
View File
@@ -2,5 +2,20 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
// Work around WebKitGTK compositing bugs on Linux Wayland:
// - WEBKIT_DISABLE_COMPOSITING_MODE=1 fixes "whole window unresponsive
// to clicks until maximize/restore" (tauri#9394)
// - WEBKIT_DISABLE_DMABUF_RENDERER=1 fixes white/black screen on some
// GPU/driver combos (e.g. Nvidia + Debian)
#[cfg(target_os = "linux")]
{
if std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err() {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").is_err() {
std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
}
}
openless_lib::run();
}
@@ -10,8 +10,8 @@
"minWidth": 980,
"minHeight": 640,
"resizable": true,
"decorations": true,
"transparent": false,
"decorations": false,
"transparent": true,
"shadow": true,
"visible": false,
"acceptFirstMouse": true
@@ -1,4 +1,4 @@
import { type CSSProperties, type ReactNode } from 'react';
import { type CSSProperties, type ReactNode, useCallback, useEffect, useRef, useState } from 'react';
export type OS = 'mac' | 'win' | 'linux';
@@ -16,6 +16,7 @@ export function detectOS(): OS {
const MAC_TITLEBAR_HEIGHT = 28;
const MAC_SYSTEM_CONTROLS_RESERVED_WIDTH = 76;
const LINUX_TITLEBAR_HEIGHT = 36;
const WIN_CONSOLE_RADIUS = 10;
interface WindowChromeProps {
@@ -30,17 +31,15 @@ export function WindowChrome({
children,
height = 800,
}: WindowChromeProps) {
// Windows 下交还原生外壳(decorations:true):外层不画圆角 / 边框 / 阴影 / 标题栏,
// 避免与原生窗口的角和关闭按钮重叠。内层卡片保留 10px 圆角,跟整体设计对齐。
// Windows: decorations:true 时外层不画圆角/边框/阴影/标题栏,避免与原生窗口重叠。
// Linux: decorations:false 时外层画 14px 圆角 + 自定义标题栏。
const shellRadius = os === 'mac' ? 0 : os === 'win' ? 0 : 14;
const consoleRadius = os === 'mac' ? 20 : os === 'win' ? WIN_CONSOLE_RADIUS : 14;
const titlebarHeight = os === 'mac' ? MAC_TITLEBAR_HEIGHT : 0;
const titlebarHeight = os === 'mac' ? MAC_TITLEBAR_HEIGHT : os === 'linux' ? LINUX_TITLEBAR_HEIGHT : 0;
// 两个平台用同一份半透明玻璃 background + backdropFilter,让 sidebar 透明地坐在
// 磨砂底板上时有可见的玻璃感。
// Windows: Tauri transparent:true + lib.rs apply_mica 提供 Win11 Mica 透出来;
// macOS: NSVisualEffectView 提供材质。
// alpha 0.92:和原生 Win11 caption(lib.rs 设为 rgb(245,245,247))色差最小,玻璃感仍可见。
// 三个平台共用半透明玻璃 background + backdropFilter。
// macOS: NSVisualEffectView 提供材质;Windows: Tauri apply_mica 提供 Mica;
// Linux: decorations:false 后 CSS 磨砂玻璃自成背景。
const background = `
radial-gradient(120% 80% at 0% 0%, rgba(255,255,255,0.55) 0%, rgba(255,255,255,0) 60%),
radial-gradient(100% 70% at 100% 100%, rgba(37,99,235,0.07) 0%, rgba(37,99,235,0) 55%),
@@ -83,9 +82,156 @@ export function WindowChrome({
}}
/>
)}
{os === 'linux' && <LinuxTitlebar />}
{os === 'linux' && (
<style>{`.ol-linux-close-btn:hover{background:rgba(220,38,38,0.12)!important;color:rgb(220,38,38)!important}`}</style>
)}
<div style={{ flex: 1, minHeight: 0, display: 'flex', position: 'relative' }}>
{children}
</div>
</div>
);
}
// ── Linux custom titlebar — mirrors cc-switch's approach ──
type TauriWindow = import('@tauri-apps/api/window').Window;
function LinuxTitlebar() {
const [maximized, setMaximized] = useState(false);
const winRef = useRef<TauriWindow | null>(null);
useEffect(() => {
let cancelled = false;
let unlisten: (() => void) | undefined;
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => {
if (cancelled) return;
const w = getCurrentWindow();
winRef.current = w;
w.isMaximized().then((m) => {
if (!cancelled) setMaximized(m);
}).catch(() => {});
// Keep icon in sync when user maximizes via double-click / keyboard shortcut
w.listen('tauri://resize', () => {
if (cancelled) return;
w.isMaximized().then((m) => {
if (!cancelled) setMaximized(m);
}).catch(() => {});
}).then((fn) => {
if (!cancelled) unlisten = fn;
}).catch(() => {});
}).catch(() => {});
return () => {
cancelled = true;
unlisten?.();
};
}, []);
const onMinimize = useCallback(() => {
winRef.current?.minimize().catch(() => {});
}, []);
const onToggleMaximize = useCallback(() => {
const w = winRef.current;
if (!w) return;
w.toggleMaximize().catch(() => {});
// Re-query after window manager processes the toggle, in case WM rejects it
setTimeout(() => {
w.isMaximized().then(setMaximized).catch(() => {});
}, 300);
}, []);
const onClose = useCallback(() => {
winRef.current?.close().catch(() => {});
}, []);
return (
<div
data-tauri-drag-region
style={{
height: LINUX_TITLEBAR_HEIGHT,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 6px 0 14px',
background: 'rgba(245,245,247,0.85)',
backdropFilter: 'blur(12px) saturate(180%)',
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
borderBottom: '0.5px solid rgba(0,0,0,0.08)',
color: 'var(--ol-ink-3)',
fontSize: 13,
fontWeight: 500,
cursor: 'default',
userSelect: 'none',
zIndex: 50,
}}
>
<span style={{ color: 'var(--ol-ink-2)' }}>OpenLess</span>
<div
style={{ display: 'flex', gap: 4, pointerEvents: 'auto' }}
onMouseDown={(e) => e.stopPropagation()}
>
<button onClick={onMinimize} aria-label="Minimize" style={ctrlBtn}>
<MinimizeSvg />
</button>
<button onClick={onToggleMaximize} aria-label={maximized ? 'Restore' : 'Maximize'} style={ctrlBtn}>
{maximized ? <RestoreSvg /> : <MaximizeSvg />}
</button>
<button
onClick={onClose}
aria-label="Close"
className="ol-linux-close-btn"
style={ctrlBtn}
>
<CloseSvg />
</button>
</div>
</div>
);
}
// ── inline SVG icons (no lucide-react dep) ──
const svgWrap: CSSProperties = { width: 12, height: 12, display: 'block' };
const ctrlBtn: CSSProperties = {
width: 30, height: 24,
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
borderRadius: 5, border: 0, padding: 0,
background: 'transparent', color: 'var(--ol-ink-3)',
fontFamily: 'inherit', cursor: 'default',
transition: 'background 0.12s, color 0.12s',
};
function MinimizeSvg() {
return (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style={svgWrap}>
<rect x="2" y="5.5" width="8" height="1" rx="0.5" fill="currentColor" />
</svg>
);
}
function MaximizeSvg() {
return (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style={svgWrap}>
<rect x="2" y="2" width="8" height="8" rx="1.5" stroke="currentColor" strokeWidth="1.1" />
</svg>
);
}
function RestoreSvg() {
return (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style={svgWrap}>
<rect x="3.6" y="0.6" width="7.2" height="7.2" rx="1.3" stroke="currentColor" strokeWidth="1.1" />
<rect x="0.6" y="3.6" width="7.2" height="7.2" rx="1.3" fill="var(--ol-surface, #fff)" stroke="currentColor" strokeWidth="1.1" />
</svg>
);
}
function CloseSvg() {
return (
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" style={svgWrap}>
<path d="M2.8 2.8l6.4 6.4M9.2 2.8l-6.4 6.4" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
</svg>
);
}