添加应用级别窗口按钮,以改善linux wayland下系统窗口按钮失效的问题 (#1119)

* feat(window): add app-level window controls with settings toggle

Add a persistent settings toggle to enable app-level minimize/maximize/close controls and hide system decorations when enabled, providing a Wayland-friendly fallback for broken native titlebar interactions.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(window): restrict app-level window controls to Linux only and fix startup flicker

- Guard useAppWindowControls with isLinux() in App.tsx so it's always
  false on macOS/Windows even if persisted as true
- Wrap set_decorations call in lib.rs with #[cfg(target_os = "linux")]
- Only show the toggle in WindowSettings on Linux
- Skip setDecorations effect while settingsData is still loading to
  prevent the Rust-side decoration state from being overridden by the
  undefined->false fallback, which caused a brief title bar flicker

---------

Co-authored-by: wzk <wx13571681304@outlook.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
v2v
2026-04-12 20:59:04 +08:00
committed by GitHub
Unverified
parent df01755328
commit cfcf9452d0
10 changed files with 200 additions and 14 deletions
+5
View File
@@ -11,6 +11,11 @@
"updater:default",
"core:window:allow-set-skip-taskbar",
"core:window:allow-start-dragging",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-is-maximized",
"core:window:allow-close",
"core:window:allow-set-decorations",
"process:allow-restart",
"dialog:default"
]
+4
View File
@@ -971,6 +971,10 @@ pub fn run() {
// 静默启动:根据设置决定是否显示主窗口
let settings = crate::settings::get_settings();
if let Some(window) = app.get_webview_window("main") {
// 在窗口首次显示前同步装饰状态,避免前端加载后再切换导致标题栏闪烁
// 仅 Linux 生效:解决 Wayland 下系统窗口按钮不可用的问题
#[cfg(target_os = "linux")]
let _ = window.set_decorations(!settings.use_app_window_controls);
if settings.silent_startup {
// 静默启动模式:保持窗口隐藏
let _ = window.hide();
+3
View File
@@ -175,6 +175,8 @@ pub struct AppSettings {
pub show_in_tray: bool,
#[serde(default = "default_minimize_to_tray_on_close")]
pub minimize_to_tray_on_close: bool,
#[serde(default)]
pub use_app_window_controls: bool,
/// 是否启用 Claude 插件联动
#[serde(default)]
pub enable_claude_plugin_integration: bool,
@@ -293,6 +295,7 @@ impl Default for AppSettings {
Self {
show_in_tray: true,
minimize_to_tray_on_close: true,
use_app_window_controls: false,
enable_claude_plugin_integration: false,
skip_claude_onboarding: false,
launch_on_startup: false,
+143 -8
View File
@@ -9,6 +9,10 @@ import {
Plus,
Settings,
ArrowLeft,
Minus,
Maximize2,
Minimize2,
X,
Book,
Wrench,
RefreshCw,
@@ -22,6 +26,7 @@ import {
Shield,
Cpu,
} from "lucide-react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import type { Provider, VisibleApps } from "@/types";
import type { EnvConflict } from "@/types/env";
import { useProvidersQuery, useSettingsQuery } from "@/lib/query";
@@ -99,9 +104,8 @@ interface WebDavSyncStatusUpdatedPayload {
error?: string;
}
const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px
const DEFAULT_DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px
const HEADER_HEIGHT = 64; // px
const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;
const STORAGE_KEY = "cc-switch-last-app";
const VALID_APPS: AppId[] = [
@@ -153,12 +157,17 @@ function App() {
const [currentView, setCurrentView] = useState<View>(getInitialView);
const [settingsDefaultTab, setSettingsDefaultTab] = useState("general");
const [isAddOpen, setIsAddOpen] = useState(false);
const [isWindowMaximized, setIsWindowMaximized] = useState(false);
useEffect(() => {
localStorage.setItem(VIEW_STORAGE_KEY, currentView);
}, [currentView]);
const { data: settingsData } = useSettingsQuery();
const useAppWindowControls =
isLinux() && (settingsData?.useAppWindowControls ?? false);
const dragBarHeight = useAppWindowControls ? 32 : DEFAULT_DRAG_BAR_HEIGHT;
const contentTopOffset = dragBarHeight + HEADER_HEIGHT;
const visibleApps: VisibleApps = settingsData?.visibleApps ?? {
claude: true,
codex: true,
@@ -392,6 +401,51 @@ function App() {
};
}, [queryClient, t]);
useEffect(() => {
let active = true;
let unlistenResize: (() => void) | undefined;
const setupWindowStateSync = async () => {
try {
const currentWindow = getCurrentWindow();
const syncWindowMaximizedState = async () => {
const maximized = await currentWindow.isMaximized();
if (active) {
setIsWindowMaximized(maximized);
}
};
await syncWindowMaximizedState();
unlistenResize = await currentWindow.onResized(() => {
void syncWindowMaximizedState();
});
} catch (error) {
console.error("[App] Failed to sync window maximized state", error);
}
};
void setupWindowStateSync();
return () => {
active = false;
unlistenResize?.();
};
}, []);
useEffect(() => {
// settingsData 未加载时跳过,避免用 fallback false 覆盖 Rust 侧已设好的装饰状态
if (!settingsData) return;
const syncWindowDecorations = async () => {
try {
await getCurrentWindow().setDecorations(!useAppWindowControls);
} catch (error) {
console.error("[App] Failed to update window decorations", error);
}
};
void syncWindowDecorations();
}, [useAppWindowControls, settingsData]);
useEffect(() => {
const checkEnvOnStartup = async () => {
try {
@@ -734,6 +788,44 @@ function App() {
}
};
const notifyWindowControlError = (error: unknown) => {
toast.error(
t("notifications.windowControlFailed", {
defaultValue: "窗口控制失败:{{error}}",
error: extractErrorMessage(error),
}),
);
};
const handleWindowMinimize = async () => {
try {
await getCurrentWindow().minimize();
} catch (error) {
console.error("[App] Failed to minimize window", error);
notifyWindowControlError(error);
}
};
const handleWindowToggleMaximize = async () => {
try {
const currentWindow = getCurrentWindow();
await currentWindow.toggleMaximize();
setIsWindowMaximized(await currentWindow.isMaximized());
} catch (error) {
console.error("[App] Failed to toggle maximize", error);
notifyWindowControlError(error);
}
};
const handleWindowClose = async () => {
try {
await getCurrentWindow().close();
} catch (error) {
console.error("[App] Failed to close window", error);
notifyWindowControlError(error);
}
};
const renderContent = () => {
const content = (() => {
switch (currentView) {
@@ -880,14 +972,57 @@ function App() {
return (
<div
className="flex flex-col h-screen overflow-hidden bg-background text-foreground selection:bg-primary/30"
style={{ overflowX: "hidden", paddingTop: CONTENT_TOP_OFFSET }}
style={{ overflowX: "hidden", paddingTop: contentTopOffset }}
>
{DRAG_BAR_HEIGHT > 0 && (
{(dragBarHeight > 0 || useAppWindowControls) && (
<div
className="fixed top-0 left-0 right-0 z-[60]"
className="fixed top-0 left-0 right-0 z-[70] flex items-center justify-end px-2"
data-tauri-drag-region
style={{ WebkitAppRegion: "drag", height: DRAG_BAR_HEIGHT } as any}
/>
style={{ WebkitAppRegion: "drag", height: dragBarHeight } as any}
>
{useAppWindowControls && (
<div
className="flex items-center gap-1"
style={{ WebkitAppRegion: "no-drag" } as any}
>
<Button
variant="ghost"
size="icon"
onClick={() => void handleWindowMinimize()}
title={t("header.windowMinimize")}
className="h-7 w-7"
>
<Minus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => void handleWindowToggleMaximize()}
title={
isWindowMaximized
? t("header.windowRestore")
: t("header.windowMaximize")
}
className="h-7 w-7"
>
{isWindowMaximized ? (
<Minimize2 className="w-4 h-4" />
) : (
<Maximize2 className="w-4 h-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => void handleWindowClose()}
title={t("header.windowClose")}
className="h-7 w-7 hover:bg-red-500/15 hover:text-red-500"
>
<X className="w-4 h-4" />
</Button>
</div>
)}
</div>
)}
{showEnvBanner && envConflicts.length > 0 && (
<EnvWarningBanner
@@ -920,7 +1055,7 @@ function App() {
style={
{
...DRAG_REGION_STYLE,
top: DRAG_BAR_HEIGHT,
top: dragBarHeight,
height: HEADER_HEIGHT,
} as any
}
@@ -3,6 +3,7 @@ import type { SettingsFormState } from "@/hooks/useSettings";
import { AppWindow, MonitorUp, Power, EyeOff } from "lucide-react";
import { ToggleRow } from "@/components/ui/toggle-row";
import { AnimatePresence, motion } from "framer-motion";
import { isLinux } from "@/lib/platform";
interface WindowSettingsProps {
settings: SettingsFormState;
@@ -75,6 +76,18 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) {
onChange({ minimizeToTrayOnClose: value })
}
/>
{isLinux() && (
<ToggleRow
icon={<AppWindow className="h-4 w-4 text-amber-500" />}
title={t("settings.useAppWindowControls")}
description={t("settings.useAppWindowControlsDescription")}
checked={!!settings.useAppWindowControls}
onCheckedChange={(value) =>
onChange({ useAppWindowControls: value })
}
/>
)}
</div>
</section>
);
+3
View File
@@ -81,6 +81,7 @@ export function useSettingsForm(): UseSettingsFormResult {
...data,
showInTray: data.showInTray ?? true,
minimizeToTrayOnClose: data.minimizeToTrayOnClose ?? true,
useAppWindowControls: data.useAppWindowControls ?? false,
enableClaudePluginIntegration:
data.enableClaudePluginIntegration ?? false,
silentStartup: data.silentStartup ?? false,
@@ -105,6 +106,7 @@ export function useSettingsForm(): UseSettingsFormResult {
({
showInTray: true,
minimizeToTrayOnClose: true,
useAppWindowControls: false,
enableClaudePluginIntegration: false,
skipClaudeOnboarding: false,
language: readPersistedLanguage(),
@@ -139,6 +141,7 @@ export function useSettingsForm(): UseSettingsFormResult {
...serverData,
showInTray: serverData.showInTray ?? true,
minimizeToTrayOnClose: serverData.minimizeToTrayOnClose ?? true,
useAppWindowControls: serverData.useAppWindowControls ?? false,
enableClaudePluginIntegration:
serverData.enableClaudePluginIntegration ?? false,
silentStartup: serverData.silentStartup ?? false,
+9 -2
View File
@@ -91,7 +91,11 @@
"switchToChinese": "Switch to Chinese",
"switchToEnglish": "Switch to English",
"enterEditMode": "Enter Edit Mode",
"exitEditMode": "Exit Edit Mode"
"exitEditMode": "Exit Edit Mode",
"windowMinimize": "Minimize window",
"windowMaximize": "Maximize window",
"windowRestore": "Restore window",
"windowClose": "Close window"
},
"provider": {
"tabProvider": "Provider",
@@ -201,7 +205,8 @@
"openclawDefaultModelSet": "Set as default model",
"openclawDefaultModelSetFailed": "Failed to set default model",
"openclawNoModels": "No models configured",
"backfillWarning": "Switched successfully, but failed to save changes back to the previous provider"
"backfillWarning": "Switched successfully, but failed to save changes back to the previous provider",
"windowControlFailed": "Window control failed: {{error}}"
},
"confirm": {
"deleteProvider": "Delete Provider",
@@ -494,6 +499,8 @@
"autoLaunchFailed": "Failed to set auto-launch",
"minimizeToTray": "Minimize to tray on close",
"minimizeToTrayDescription": "When checked, clicking the close button will hide to system tray, otherwise the app will exit directly.",
"useAppWindowControls": "Enable app-level window controls",
"useAppWindowControlsDescription": "Use built-in minimize, maximize/restore, and close buttons in the app header.",
"enableClaudePluginIntegration": "Apply to Claude Code extension",
"enableClaudePluginIntegrationDescription": "When enabled, the VS Code Claude Code extension provider will switch with this app",
"skipClaudeOnboarding": "Skip Claude Code first-run confirmation",
+9 -2
View File
@@ -91,7 +91,11 @@
"switchToChinese": "中国語に切り替え",
"switchToEnglish": "英語に切り替え",
"enterEditMode": "編集モードに入る",
"exitEditMode": "編集モードを終了"
"exitEditMode": "編集モードを終了",
"windowMinimize": "ウィンドウを最小化",
"windowMaximize": "ウィンドウを最大化",
"windowRestore": "ウィンドウを元に戻す",
"windowClose": "ウィンドウを閉じる"
},
"provider": {
"tabProvider": "プロバイダー",
@@ -201,7 +205,8 @@
"openclawDefaultModelSet": "デフォルトモデルに設定しました",
"openclawDefaultModelSetFailed": "デフォルトモデルの設定に失敗しました",
"openclawNoModels": "モデルが設定されていません",
"backfillWarning": "切り替え成功しましたが、前のプロバイダーへの設定保存に失敗しました"
"backfillWarning": "切り替え成功しましたが、前のプロバイダーへの設定保存に失敗しました",
"windowControlFailed": "ウィンドウ操作に失敗しました: {{error}}"
},
"confirm": {
"deleteProvider": "プロバイダーを削除",
@@ -494,6 +499,8 @@
"autoLaunchFailed": "自動起動の設定に失敗しました",
"minimizeToTray": "閉じるときトレイへ最小化",
"minimizeToTrayDescription": "チェックすると閉じるボタンでトレイに隠し、オフならアプリを終了します。",
"useAppWindowControls": "アプリ内ウィンドウボタンを有効化",
"useAppWindowControlsDescription": "有効にすると、アプリヘッダーに最小化・最大化/復元・閉じるボタンを表示します。",
"enableClaudePluginIntegration": "Claude Code 拡張に適用",
"enableClaudePluginIntegrationDescription": "オンにすると VS Code の Claude Code 拡張のプロバイダーも同期します",
"skipClaudeOnboarding": "Claude Code の初回確認をスキップ",
+9 -2
View File
@@ -91,7 +91,11 @@
"switchToChinese": "切换到中文",
"switchToEnglish": "切换到英文",
"enterEditMode": "进入编辑模式",
"exitEditMode": "退出编辑模式"
"exitEditMode": "退出编辑模式",
"windowMinimize": "最小化窗口",
"windowMaximize": "最大化窗口",
"windowRestore": "还原窗口",
"windowClose": "关闭窗口"
},
"provider": {
"tabProvider": "供应商",
@@ -201,7 +205,8 @@
"openclawDefaultModelSet": "已设为默认模型",
"openclawDefaultModelSetFailed": "设置默认模型失败",
"openclawNoModels": "该供应商没有配置模型",
"backfillWarning": "切换成功,但旧供应商配置回填失败,您手动修改的配置可能未保存"
"backfillWarning": "切换成功,但旧供应商配置回填失败,您手动修改的配置可能未保存",
"windowControlFailed": "窗口控制失败:{{error}}"
},
"confirm": {
"deleteProvider": "删除供应商",
@@ -494,6 +499,8 @@
"autoLaunchFailed": "设置开机自启失败",
"minimizeToTray": "关闭时最小化到托盘",
"minimizeToTrayDescription": "勾选后点击关闭按钮会隐藏到系统托盘,取消则直接退出应用。",
"useAppWindowControls": "启用应用级窗口按钮",
"useAppWindowControlsDescription": "开启后使用应用自建的最小化、最大化/还原、关闭按钮;关闭后沿用系统窗口模式。",
"enableClaudePluginIntegration": "应用到 Claude Code 插件",
"enableClaudePluginIntegrationDescription": "开启后 Vscode Claude Code 插件的供应商将随本软件切换",
"skipClaudeOnboarding": "跳过 Claude Code 初次安装确认",
+2
View File
@@ -244,6 +244,8 @@ export interface Settings {
showInTray: boolean;
// 点击关闭按钮时是否最小化到托盘而不是关闭应用
minimizeToTrayOnClose: boolean;
// 是否启用应用级窗口控制按钮(最小化/最大化/关闭)
useAppWindowControls?: boolean;
// 启用 Claude 插件联动(写入 ~/.claude/config.json 的 primaryApiKey
enableClaudePluginIntegration?: boolean;
// 跳过 Claude Code 初次安装确认(写入 ~/.claude.json 的 hasCompletedOnboarding