diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 5614cb25e..8132efe91 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -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" ] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7f17c0b43..dbfb63eae 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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(); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index c9b4a4b41..8b41ba517 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -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, diff --git a/src/App.tsx b/src/App.tsx index 348ff6d71..f7470a18a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(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 (
- {DRAG_BAR_HEIGHT > 0 && ( + {(dragBarHeight > 0 || useAppWindowControls) && (
+ style={{ WebkitAppRegion: "drag", height: dragBarHeight } as any} + > + {useAppWindowControls && ( +
+ + + +
+ )} +
)} {showEnvBanner && envConflicts.length > 0 && ( + + {isLinux() && ( + } + title={t("settings.useAppWindowControls")} + description={t("settings.useAppWindowControlsDescription")} + checked={!!settings.useAppWindowControls} + onCheckedChange={(value) => + onChange({ useAppWindowControls: value }) + } + /> + )}
); diff --git a/src/hooks/useSettingsForm.ts b/src/hooks/useSettingsForm.ts index 72530920b..0944b91d1 100644 --- a/src/hooks/useSettingsForm.ts +++ b/src/hooks/useSettingsForm.ts @@ -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, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 6a4fc6215..b28e876c1 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 65d0c4aaf..a8ba0cf51 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -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 の初回確認をスキップ", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 0bd66025e..421d7f859 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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 初次安装确认", diff --git a/src/types.ts b/src/types.ts index 90cb06a22..28569b4df 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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)