From bd34cc4643d09d07ca98b40a65844a9541545bed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Fri, 18 Sep 2026 16:30:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(tray):=20=E5=85=B3=E9=97=AD=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E8=AF=A2=E9=97=AE=E3=80=8C=E6=9C=80=E5=B0=8F=E5=8C=96?= =?UTF-8?q?=E5=88=B0=E6=89=98=E7=9B=98=20/=20=E9=80=80=E5=87=BA=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E3=80=8D=EF=BC=8C=E5=B9=B6=E4=BF=AE=E5=A4=8D=20Window?= =?UTF-8?q?s=20=E9=87=8D=E5=A4=8D=E5=90=AF=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 关闭行为从「托盘可用就隐藏」改成先看用户偏好: 1. 新增 `AppSettings.close_button_behavior`(`ask` / `minimize_to_tray` / `quit`, 默认 `ask`,未知值回退 `ask`),`main_window_close_action(tray_ready, behavior)` 纯策略三态:托盘不可用一律 `RequestQuit`,不与偏好互动 —— 宁可退出也不能 制造用户找不到、也没法恢复的隐藏窗口。 2. `AskUser` 弹窗的两条出路各自渲染按钮(`tray-close-minimize` / `tray-close-quit`), 带「记住我的选择」,勾选后落盘。刻意不用默认 ok/cancel footer:默认 footer 只有两个固定按钮,「取消」兼职退出会让 Esc 和点遮罩也走成退出应用;而 `button_props` 跟在 `confirm` 之后设置还会把 `show_cancel` 重置、吞掉第二个 按钮(前一版就是这么丢的)。 3. 「记住」勾选框放在独立 entity:弹窗 body 由 `Root` 渲染,`NavopApp::notify` 重绘不到它。`close_choice_prompt_open` 防连点叠窗,关窗复位。 4. 设置页通用页新增「关闭窗口行为」下拉,与弹窗写同一份偏好;托盘不可用时 该设置不生效。 Windows 单实例:interprocess 的命名管道带 `FILE_FLAG_FIRST_PIPE_INSTANCE`,名字 已存在时返回 `ERROR_ACCESS_DENIED(5)` 而非 `AddrInUse`,且错误原样透传、无 error kind 归一化,旧判定永不命中 ⇒ 第二个实例把自己当主实例、起出完整进程。改为 `instance_name_taken()` 认 `AddrInUse` 或 `raw_os_error` ∈ {5, 231},其余错误不 误判为「已占用」。 运行:`cargo test -p one-core --lib settings`(112 passed)、`cargo test -p main` (634 passed);`cargo check -p main --all-targets`、`cargo clippy -p main --all-targets`(改动文件零告警)通过。Windows 重复启动与弹窗交互仍需真机冒烟。 --- crates/core/src/settings.rs | 95 +++++- ...26-07-13-minimize-to-system-tray-design.md | 76 ++++- main/locales/main.yml | 50 ++++ main/src/navop_app.rs | 275 ++++++++++++++++-- main/src/setting_tab.rs | 47 ++- main/src/system_tray.rs | 53 +++- main/src/windows_single_instance.rs | 44 ++- 7 files changed, 590 insertions(+), 50 deletions(-) diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs index 119400f58..1e1ec24c2 100644 --- a/crates/core/src/settings.rs +++ b/crates/core/src/settings.rs @@ -888,12 +888,48 @@ fn default_connection_sidebar_tree_width() -> u32 { DEFAULT_CONNECTION_SIDEBAR_TREE_WIDTH } +/// 点击主窗口关闭按钮时的行为。 +/// +/// 只在系统托盘可用时生效:托盘不可用时一律回退到退出确认,不制造无法恢复的隐藏窗口。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CloseButtonBehavior { + /// 弹窗询问:最小化到托盘,还是退出应用(弹窗内可记住选择) + #[default] + Ask, + /// 直接最小化到托盘 + MinimizeToTray, + /// 直接走退出确认 + Quit, +} + +impl CloseButtonBehavior { + pub fn as_str(&self) -> &'static str { + match self { + CloseButtonBehavior::Ask => "ask", + CloseButtonBehavior::MinimizeToTray => "minimize_to_tray", + CloseButtonBehavior::Quit => "quit", + } + } + + /// 未知取值回退到 [`CloseButtonBehavior::Ask`]:旧版本写下的值不能把新版本卡死。 + pub fn from_str(value: &str) -> Self { + match value { + "minimize_to_tray" => CloseButtonBehavior::MinimizeToTray, + "quit" => CloseButtonBehavior::Quit, + _ => CloseButtonBehavior::Ask, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppSettings { #[serde(default)] pub main_window_size: Option, #[serde(default)] pub main_window_state: Option, + #[serde(default)] + pub close_button_behavior: CloseButtonBehavior, #[serde(default = "default_locale")] pub locale: String, #[serde(default = "default_theme_mode")] @@ -1312,6 +1348,7 @@ impl Default for AppSettings { Self { main_window_size: None, main_window_state: None, + close_button_behavior: CloseButtonBehavior::default(), locale: default_locale(), theme_mode: default_theme_mode(), auto_switch_theme: false, @@ -1693,9 +1730,10 @@ mod tests { use gpui_component::{Theme, ThemeMode}; use super::{ - AiChatSettings, AiChatToolExecutionMode, AppSettings, ConnectionSortOrder, CustomFont, - DEFAULT_AI_REQUEST_TIMEOUT_SECS, DEFAULT_MCP_APPROVAL_TIMEOUT_MS, DEFAULT_TERMINAL_THEME, - HomeConnectionLayout, LOCALE_SYSTEM, LargeTextCellEditorOpenMode, LocalTerminalProfileKind, + AiChatSettings, AiChatToolExecutionMode, AppSettings, CloseButtonBehavior, + ConnectionSortOrder, CustomFont, DEFAULT_AI_REQUEST_TIMEOUT_SECS, + DEFAULT_MCP_APPROVAL_TIMEOUT_MS, DEFAULT_TERMINAL_THEME, HomeConnectionLayout, + LOCALE_SYSTEM, LargeTextCellEditorOpenMode, LocalTerminalProfileKind, LocalTerminalProfileSettings, MAX_AI_REQUEST_TIMEOUT_SECS, MAX_CUSTOM_SYSTEM_PROMPT_CHARS, MIN_AI_REQUEST_TIMEOUT_SECS, MainWindowState, McpPermissionMode, McpServerMode, PersonalSyncBackendKind, RemoteFileOpenMode, SqlFormatSettings, SqlIndentStyle, @@ -1779,6 +1817,57 @@ mod tests { assert!(settings.main_window_state.is_none()); } + #[test] + fn close_button_behavior_defaults_to_asking_every_time() { + assert_eq!( + CloseButtonBehavior::Ask, + AppSettings::default().close_button_behavior + ); + } + + #[test] + fn legacy_app_settings_without_close_button_behavior_ask_every_time() { + let settings: AppSettings = + serde_json::from_value(serde_json::json!({})).expect("旧版设置应能反序列化"); + + assert_eq!(CloseButtonBehavior::Ask, settings.close_button_behavior); + } + + #[test] + fn close_button_behavior_round_trips_through_settings_json() { + for behavior in [ + CloseButtonBehavior::Ask, + CloseButtonBehavior::MinimizeToTray, + CloseButtonBehavior::Quit, + ] { + let mut settings = AppSettings::default(); + settings.close_button_behavior = behavior; + + let json = serde_json::to_value(&settings).expect("serialize settings"); + let restored: AppSettings = serde_json::from_value(json).expect("deserialize settings"); + + assert_eq!(behavior, restored.close_button_behavior); + } + } + + #[test] + fn close_button_behavior_strings_are_closed_and_fall_back_to_asking() { + for behavior in [ + CloseButtonBehavior::Ask, + CloseButtonBehavior::MinimizeToTray, + CloseButtonBehavior::Quit, + ] { + assert_eq!(behavior, CloseButtonBehavior::from_str(behavior.as_str())); + } + + // 未知取值必须回退到询问,而不是静默变成「退出应用」。 + assert_eq!( + CloseButtonBehavior::Ask, + CloseButtonBehavior::from_str("something_else") + ); + assert_eq!(CloseButtonBehavior::Ask, CloseButtonBehavior::from_str("")); + } + #[test] fn app_settings_enables_direct_server_transfer_by_default() { assert!(AppSettings::default().direct_server_transfer_enabled); diff --git a/docs/superpowers/specs/2026-07-13-minimize-to-system-tray-design.md b/docs/superpowers/specs/2026-07-13-minimize-to-system-tray-design.md index a9e12f8e4..f95c86fb4 100644 --- a/docs/superpowers/specs/2026-07-13-minimize-to-system-tray-design.md +++ b/docs/superpowers/specs/2026-07-13-minimize-to-system-tray-design.md @@ -10,9 +10,14 @@ ### 关闭与最小化 -- 点击主窗口关闭按钮时: - - 托盘初始化成功,则隐藏主窗口,不关闭窗口实体、不销毁标签页、不终止进程; - - 托盘初始化失败,则回退到现有退出确认,禁止产生无法恢复的隐藏窗口。 +- 点击主窗口关闭按钮时,托盘可用则按用户偏好分流: + - 偏好为「每次询问」(默认):弹窗让用户当场选择「最小化到托盘」还是「退出应用」, + 弹窗内提供「记住我的选择」,勾选后写入设置,之后不再询问; + - 偏好为「最小化到托盘」:直接隐藏主窗口,不关闭窗口实体、不销毁标签页、不终止进程; + - 偏好为「退出应用」:走现有退出确认与标签页关闭检查。 +- 托盘初始化失败时不弹窗:任何偏好都不能制造无法恢复的隐藏窗口,一律回退到现有退出确认。 +- 隐藏到托盘这一步失败时同样回退退出确认(宁可直接退出,也不留下找不到的隐藏窗口)。 +- 托盘可用性的判断先于偏好:偏好只在托盘真的可用时才起作用。 - 点击系统最小化按钮时,继续使用 GPUI 当前的系统最小化行为。 - Windows 当前绑定为 `QuitApp` 的 `Alt+F4`、macOS 的 `Cmd+Q`、应用菜单退出操作继续表示显式退出,不改为隐藏。 @@ -153,16 +158,41 @@ App 借用 ⇒ 二次借用失败,日志只剩 `ERROR gpui::window: RefCell al ```rust enum MainWindowCloseAction { + AskUser, HideToTray, RequestQuit, } + +fn main_window_close_action( + tray_ready: bool, + behavior: CloseButtonBehavior, +) -> MainWindowCloseAction; ``` -- 托盘可用时返回 `HideToTray`; -- 托盘不可用时返回 `RequestQuit`。 +- 托盘不可用时一律返回 `RequestQuit`,无视偏好; +- 托盘可用时按 `AppSettings.close_button_behavior` 返回 + `Ask` → `AskUser`、`MinimizeToTray` → `HideToTray`、`Quit` → `RequestQuit`。 执行 `HideToTray` 时调用窗口可见性适配器并返回 `false`,阻止 GPUI 销毁窗口。若隐藏失败,立即调用现有 `request_quit`,仍返回 `false`,由现有退出流程决定是否退出。 +`AskUser` 时同样返回 `false`,由弹窗决定后续: + +- 弹窗用 `WindowExt::open_dialog` 打开,两条出路用自定义 `DialogFooter` 各自渲染一个 + 按钮(`tray-close-minimize` / `tray-close-quit`),点击后先 `window.close_dialog(cx)` + 再走对应流程。**不要用默认的 ok/cancel footer**:默认 footer 只有两个固定按钮, + 把「取消」当成第二条出路会让 Esc 和点遮罩也走成退出应用;而 `button_props` 若在 + `confirm` 之后设置,还会把 `show_cancel` 重置、直接吞掉第二个按钮; +- 「记住我的选择」的勾选状态放在独立 entity(`CloseChoiceRememberState`)里: + 弹窗 body 由 `Root` 渲染,`NavopApp` 的 `notify()` 不会重绘它,勾选框必须自己刷新; +- `close_choice_prompt_open` 标记防止连点关闭按钮叠出第二个弹窗,弹窗被关掉时复位; +- 用户选择「最小化到托盘」沿用 `HideToTray` 路径,选择「退出应用」沿用现有 + `request_quit`,两条去向都复用既有流程,弹窗只负责选一次; +- 勾选「记住」时把结果写入 `AppSettings.close_button_behavior` 并落盘。 + +偏好同时在设置页「通用 → 关闭窗口行为」提供下拉项(默认值即 `Ask`), +文案键为 `Settings.General.CloseBehavior.*`,托盘弹窗文案键为 `Tray.*`, +三套语言(en / zh-CN / zh-HK)齐备。 + ### 显式退出复用 托盘“退出 Navop”不得直接调用 `cx.quit()`。统一流程为: @@ -213,8 +243,13 @@ enum MainWindowCloseAction { ### 纯逻辑测试 -- 托盘可用时关闭策略返回 `HideToTray`; -- 托盘不可用时关闭策略返回 `RequestQuit`; +- 托盘可用且偏好为「每次询问」时关闭策略返回 `AskUser`;偏好为「最小化到托盘」返回 + `HideToTray`,偏好为「退出应用」返回 `RequestQuit`; +- 托盘不可用时关闭策略返回 `RequestQuit`;任何偏好都不能隐藏窗口; +- 关闭弹窗同时提供「最小化到托盘」与「退出应用」两条去向,并带「记住我的选择」勾选框; +- 勾选记住后偏好写入 `AppSettings.close_button_behavior` 并落盘; +- 用户偏好三值(`ask` / `minimize_to_tray` / `quit`)在设置 JSON 上 round-trip, + 旧 JSON 与未知值回退 `ask`; - 托盘图标点击(左键抬起)映射为 `ShowMainWindow`;左键按下、右键、中键都不映射; - “显示 Navop”映射为 `ShowMainWindow`; - “退出 Navop”映射为 `QuitApplication`; @@ -226,7 +261,11 @@ enum MainWindowCloseAction { - 主应用使用 `QuitMode::Explicit`; - 托盘初始化发生在窗口系统初始化之后、`OnetCliApp` 安装关闭 handler 之前; -- 主窗口关闭 handler 不再无条件调用 `request_quit`,而是先过托盘策略; +- 主窗口关闭 handler 不再无条件调用 `request_quit`,而是先过托盘策略,并区分 + `AskUser` / `HideToTray` / `RequestQuit` 三条分支; +- 关闭弹窗用自定义 footer 渲染两条去向按钮与记住勾选框,不回退到默认 ok/cancel footer; + 连点关闭按钮不会叠弹窗;Esc 与点遮罩只关询问、不触发退出; +- 设置页通用页存在「关闭窗口行为」下拉项,三套语言文案齐备; - 托盘退出路径调用现有 `request_window_quit`,不直接调用 `cx.quit()`; - 平台回调只入队命令,不触碰 `Window` / `update_window`; - `TrayIcon` 保存在 `thread_local!` 中而不是任何 `Send` 容器里; @@ -254,6 +293,24 @@ enum MainWindowCloseAction { 7. 再次选择退出并确认,确认应用进程终止且托盘图标消失; 8. 从 Dock reopen 隐藏中的应用,确认主窗口恢复。 +## 附:Windows 单实例重复启动修复 + +症状:Windows 上重复双击启动 Navop 会开出第二个完整进程,第二个进程不再把启动请求 +转发给已有实例。 + +根因(`main/src/windows_single_instance.rs` + `interprocess` 2.4.4): + +- `interprocess` 的 Windows 命名管道监听器带 `FILE_FLAG_FIRST_PIPE_INSTANCE`, + 名字已存在时 `CreateNamedPipeW` 返回 `ERROR_ACCESS_DENIED(5)`; +- 该错误被原样透传,没有做 error kind 归一化,所以 `kind()` 永远不是 `AddrInUse`; +- 于是「名字已被占用」的分支在 Windows 上永不命中,第二个实例误判自己为主实例并继续 + 完整启动。 + +修复:把判定抽成 `instance_name_taken(&io::Error)`,接受 `AddrInUse` 或 +`raw_os_error()` ∈ {`ERROR_ACCESS_DENIED(5)`, `ERROR_PIPE_BUSY(231)`};其余错误 +(如 `PermissionDenied`)不能被误判为「已占用」,否则主实例会把启动请求转发给不存在 +的管道。不更换机制,仍是命名管道 + 转发启动路径;转发失败仍按现状记录日志后继续启动。 + ## 风险与取舍 - Linux 桌面环境不一定提供 StatusNotifierItem watcher。此时功能明确降级为原有关闭确认,而不是隐藏到不可恢复状态。 @@ -265,12 +322,13 @@ enum MainWindowCloseAction { ## 验收标准 - macOS、Windows 和支持 StatusNotifierItem 的 Linux 桌面显示 Navop 托盘图标; -- 点击主窗口关闭按钮时,托盘可用的平台保留进程和主窗口状态; +- 点击主窗口关闭按钮时,托盘可用的平台保留进程和主窗口状态;首次关闭按偏好询问并记住选择; - 系统最小化按钮保持原行为; - 托盘单击和“显示 Navop”恢复同一个主窗口,不创建重复窗口; - 托盘“退出 Navop”进入现有退出确认和标签页关闭检查; - `Cmd+Q`、`Alt+F4` 和应用菜单退出继续表示显式退出; - 托盘初始化或窗口隐藏失败时回退现有退出确认; +- Windows 重复启动只保留一个实例,第二个进程把启动请求转发给已有实例; - macOS Dock reopen 可恢复隐藏窗口; - Linux X11 使用真正隐藏,Wayland 使用有记录的最小化回退; - 托盘图标使用内嵌的现有 Navop 品牌资源; diff --git a/main/locales/main.yml b/main/locales/main.yml index c2e9b3980..9498a8a3f 100644 --- a/main/locales/main.yml +++ b/main/locales/main.yml @@ -78,6 +78,29 @@ Quit: zh-CN: 退出 zh-HK: 結束 +# 系统托盘 +Tray: + close_title: + en: Close Navop? + zh-CN: 关闭 Navop? + zh-HK: 關閉 Navop? + close_message: + en: Minimize Navop to the system tray and keep everything running, or quit the application. + zh-CN: 可以最小化到系统托盘并保持连接与后台任务继续运行,也可以直接退出应用。 + zh-HK: 可以最小化到系統匣並保持連線與背景工作繼續執行,也可以直接結束應用程式。 + close_minimize: + en: Minimize to tray + zh-CN: 最小化到托盘 + zh-HK: 最小化到系統匣 + close_quit: + en: Quit + zh-CN: 退出应用 + zh-HK: 結束應用程式 + close_remember: + en: Remember my choice and stop asking + zh-CN: 记住我的选择,不再询问 + zh-HK: 記住我的選擇,不再詢問 + # MCP 审批 McpApproval: dialog_title: @@ -2355,6 +2378,33 @@ Settings: zh-CN: 可用时提供服务器间直接传输;关闭后,服务器间复制将始终通过 Navop 中转。 zh-HK: 可用時提供伺服器間直接傳輸;關閉後,伺服器間複製將始終透過 Navop 中轉。 + # 关闭主窗口行为 + CloseBehavior: + group_title: + en: Closing the Window + zh-CN: 关闭窗口行为 + zh-HK: 關閉視窗行為 + behavior: + en: When Closing the Main Window + zh-CN: 点击关闭按钮时 + zh-HK: 點擊關閉按鈕時 + behavior_desc: + en: Only applies when the system tray is available. "Ask" shows a dialog with the choice to minimize to the tray or quit, and you can remember that choice there. + zh-CN: 仅在系统托盘可用时生效。「每次询问」会弹窗让你选择最小化到托盘还是退出应用,并可在弹窗里记住选择。 + zh-HK: 僅在系統匣可用時生效。「每次詢問」會彈窗讓你選擇最小化到系統匣還是結束應用程式,並可在彈窗裡記住選擇。 + ask: + en: Ask every time + zh-CN: 每次询问 + zh-HK: 每次詢問 + minimize_to_tray: + en: Minimize to tray + zh-CN: 最小化到托盘 + zh-HK: 最小化到系統匣 + quit: + en: Quit the application + zh-CN: 退出应用 + zh-HK: 結束應用程式 + # 笔记设置 Notes: group_title: diff --git a/main/src/navop_app.rs b/main/src/navop_app.rs index c3972e460..2aaf2c1cf 100644 --- a/main/src/navop_app.rs +++ b/main/src/navop_app.rs @@ -9,9 +9,17 @@ use gpui::{ App, AppContext, AsyncApp, Context, Entity, ExternalPaths, InteractiveElement, IntoElement, KeyBinding, Keystroke, ParentElement, Render, Styled, Task, Window, actions, div, }; -use gpui_component::{WindowExt, dialog::DialogButtonProps, kbd::Kbd, notification::Notification}; +use gpui_component::{ + WindowExt, + button::{Button, ButtonVariant, ButtonVariants as _}, + checkbox::Checkbox, + dialog::{DialogButtonProps, DialogFooter}, + kbd::Kbd, + notification::Notification, +}; use one_core::gpui_tokio::{JoinError, Tokio}; use one_core::keybindings::{action_id, rebind_keybindings, shortcuts_for}; +use one_core::settings::CloseButtonBehavior; use raw_window_handle::HasWindowHandle; #[cfg(any(target_os = "macos", target_os = "windows"))] use raw_window_handle::RawWindowHandle; @@ -1376,10 +1384,33 @@ pub struct NavopApp { tab_container: Entity, connection_sidebar: Entity, quit_state: QuitRequestState, + /// 「最小化到托盘 / 退出应用」弹窗是否已经打开:标题栏关闭按钮可能被连点, + /// 不能叠出第二个完全相同的弹窗。 + close_choice_prompt_open: bool, main_window_size_save_task: Option>, _appearance_subscription: gpui::Subscription, } +/// 关闭弹窗里「记住我的选择」勾选框的状态。 +/// +/// 刻意做成独立实体而不是直接读 [`NavopApp`] 的字段:弹窗 body 由 `Root` 渲染, +/// `NavopApp` 的 `notify()` 不会重绘它,勾选框必须自己负责刷新。 +struct CloseChoiceRememberState { + remember: bool, +} + +impl Render for CloseChoiceRememberState { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + Checkbox::new("tray-close-remember") + .checked(self.remember) + .label(t!("Tray.close_remember").to_string()) + .on_click(cx.listener(|this, checked, _, cx| { + this.remember = *checked; + cx.notify(); + })) + } +} + impl NavopApp { pub fn new(window: &mut Window, cx: &mut Context) -> Self { let app_entity = cx.entity(); @@ -1388,20 +1419,21 @@ impl NavopApp { }); let app = app_entity.downgrade(); window.on_window_should_close(cx, move |window, cx| { - match crate::system_tray::main_window_close_action(crate::system_tray::is_available()) { + match crate::system_tray::main_window_close_action( + crate::system_tray::is_available(), + AppSettings::current(cx).close_button_behavior, + ) { + crate::system_tray::MainWindowCloseAction::AskUser => { + // 托盘可用但用户还没固化偏好:先问「最小化到托盘还是退出」, + // 窗口一律保留(返回 false),由弹窗里的选择决定下一步。 + let _ = app.update(cx, |app, cx| { + app.show_close_choice_prompt(window, cx); + }); + } crate::system_tray::MainWindowCloseAction::HideToTray => { - // 隐藏失败必须回退到退出确认:宁可直接退出,也不能留下一个 - // 用户找不到、也没法恢复的隐藏窗口。 - if let Err(error) = crate::window_visibility::hide_main_window(window) { - tracing::warn!(%error, "隐藏主窗口失败,回退到退出确认"); - let _ = app.update(cx, |app, cx| { - app.request_quit(window, cx); - }); - } else { - // 冒烟与排障的唯一可观测点:窗口不可见之后,日志是唯一能证明 - // 「关闭按钮走的是托盘路径、而不是退出路径」的证据。 - tracing::info!("主窗口已隐藏到系统托盘,进程继续在后台运行"); - } + let _ = app.update(cx, |app, cx| { + app.hide_main_window_to_tray(window, cx); + }); } crate::system_tray::MainWindowCloseAction::RequestQuit => { let _ = app.update(cx, |app, cx| { @@ -1576,6 +1608,7 @@ impl NavopApp { tab_container, connection_sidebar, quit_state: QuitRequestState::default(), + close_choice_prompt_open: false, main_window_size_save_task: None, _appearance_subscription: appearance_subscription, } @@ -1647,6 +1680,124 @@ impl NavopApp { }); } + /// 关闭按钮的「最小化到托盘 / 退出应用」弹窗。 + /// + /// 只在托盘可用且用户没有固化过偏好时打开;两条去向都复用既有流程 + /// (隐藏到托盘 / 退出确认),弹窗本身只负责让用户选一次。 + fn show_close_choice_prompt(&mut self, window: &mut Window, cx: &mut Context) { + if self.close_choice_prompt_open { + return; + } + self.close_choice_prompt_open = true; + + let remember = cx.new(|_| CloseChoiceRememberState { remember: false }); + let app_for_minimize = cx.entity().downgrade(); + let app_for_quit = cx.entity().downgrade(); + let app_for_close = cx.entity().downgrade(); + + window.open_dialog(cx, move |dialog, _window, _cx| { + let remember_for_minimize = remember.clone(); + let remember_for_quit = remember.clone(); + let app_for_minimize = app_for_minimize.clone(); + let app_for_quit = app_for_quit.clone(); + let app_for_close = app_for_close.clone(); + + // 两条出路都自己渲染,不用默认的 ok/cancel footer:默认 footer 只有两个固定 + // 按钮,「取消」一旦被当成第二条出路,Esc 和点遮罩也会走成退出应用;并且 + // button_props 若在 confirm 之后设置,还会把 show_cancel 重置、吞掉第二个按钮。 + let minimize_button = Button::new("tray-close-minimize") + .label(t!("Tray.close_minimize").to_string()) + .with_variant(ButtonVariant::Primary) + .on_click(move |_, window, cx| { + let remember = remember_for_minimize.read(cx).remember; + window.close_dialog(cx); + let _ = app_for_minimize.update(cx, |app, cx| { + app.minimize_main_window_to_tray(remember, window, cx); + }); + }); + let quit_button = Button::new("tray-close-quit") + .label(t!("Tray.close_quit").to_string()) + .on_click(move |_, window, cx| { + let remember = remember_for_quit.read(cx).remember; + window.close_dialog(cx); + let _ = app_for_quit.update(cx, |app, cx| { + app.quit_from_close_choice_prompt(remember, window, cx); + }); + }); + + dialog + .title(t!("Tray.close_title").to_string()) + .child(t!("Tray.close_message").to_string()) + .child(remember.clone()) + .footer( + DialogFooter::new() + .child(quit_button) + .child(minimize_button), + ) + .on_close(move |_, _, cx| { + // Esc 或点遮罩只是关掉询问,不做任何动作;但不能留下「弹窗还开着」 + // 的标记,否则下次点关闭按钮再也不弹。 + let _ = app_for_close.update(cx, |app, _cx| { + app.close_choice_prompt_open = false; + }); + }) + }); + } + + /// 弹窗选择「最小化到托盘」。 + fn minimize_main_window_to_tray( + &mut self, + remember: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.close_choice_prompt_open = false; + if remember { + self.remember_close_button_behavior(CloseButtonBehavior::MinimizeToTray, cx); + } + self.hide_main_window_to_tray(window, cx); + } + + /// 弹窗选择「退出应用」。 + fn quit_from_close_choice_prompt( + &mut self, + remember: bool, + window: &mut Window, + cx: &mut Context, + ) { + self.close_choice_prompt_open = false; + if remember { + self.remember_close_button_behavior(CloseButtonBehavior::Quit, cx); + } + self.request_quit(window, cx); + } + + fn remember_close_button_behavior( + &mut self, + behavior: CloseButtonBehavior, + cx: &mut Context, + ) { + AppSettings::update_and_save(cx, |settings| { + settings.close_button_behavior = behavior; + }); + } + + /// 隐藏主窗口到托盘。 + /// + /// 隐藏失败必须回退到退出确认:宁可直接退出,也不能留下一个用户找不到、 + /// 也没法恢复的隐藏窗口。 + fn hide_main_window_to_tray(&mut self, window: &mut Window, cx: &mut Context) { + match crate::window_visibility::hide_main_window(window) { + // 冒烟与排障的唯一可观测点:窗口不可见之后,日志是唯一能证明 + // 「关闭按钮走的是托盘路径、而不是退出路径」的证据。 + Ok(()) => tracing::info!("主窗口已隐藏到系统托盘,进程继续在后台运行"), + Err(error) => { + tracing::warn!(%error, "隐藏主窗口失败,回退到退出确认"); + self.request_quit(window, cx); + } + } + } + fn request_quit(&mut self, window: &mut Window, cx: &mut Context) { self.save_main_window_state(window, cx); if self.tab_container.read(cx).is_empty() { @@ -2071,11 +2222,89 @@ mod tests { "关闭按钮必须按托盘可用性分流" ); assert!(contains_code(new_fn, &availability_fn)); + assert!(contains_code(new_fn, "MainWindowCloseAction::AskUser")); + assert!(contains_code(new_fn, "show_close_choice_prompt")); assert!(contains_code(new_fn, "MainWindowCloseAction::HideToTray")); - assert!(contains_code(new_fn, "window_visibility::hide_main_window")); + assert!(contains_code(new_fn, "hide_main_window_to_tray")); assert!(contains_code(new_fn, "MainWindowCloseAction::RequestQuit")); } + /// 弹窗必须真的给用户两条出路(最小化 / 退出),并且「记住我的选择」写回设置; + /// 少了任一条,用户点关闭按钮就会被卡在一个只有默认按钮的弹窗里。 + #[test] + fn close_choice_prompt_offers_both_destinations_and_remembers_the_choice() { + let source = include_str!("navop_app.rs"); + let prompt = fn_body(source, "fn show_close_choice_prompt"); + let remember_label = ["Tray.close_", "remember"].concat(); + let minimize_label = ["Tray.close_", "minimize"].concat(); + let quit_label = ["Tray.close_", "quit"].concat(); + + // 两条出路必须是各自独立的按钮:默认 footer 只有 ok/cancel, + // button_props 跟在 confirm 后面还会把 show_cancel 重置掉、吞掉第二个按钮, + // 所以这里禁止回退到默认 footer。 + assert!(contains_code(prompt, "DialogFooter::new()")); + assert!(contains_code( + prompt, + r#"Button::new("tray-close-minimize")"# + )); + assert!(contains_code(prompt, r#"Button::new("tray-close-quit")"#)); + assert!(!contains_code(prompt, ".confirm()")); + assert!(!contains_code(prompt, ".button_props(")); + assert!(contains_code(prompt, &minimize_label)); + assert!(contains_code(prompt, &quit_label)); + assert!(contains_code(prompt, "window.close_dialog(cx)")); + assert!(contains_code(prompt, "CloseChoiceRememberState")); + assert!(contains_code(prompt, "minimize_main_window_to_tray")); + assert!(contains_code(prompt, "quit_from_close_choice_prompt")); + // 连点关闭按钮不能叠出第二个弹窗。 + assert!(contains_code(prompt, "if self.close_choice_prompt_open")); + + // 「记住我的选择」勾选框自带实体,标签与受控回写都在它的 render 里。 + let checkbox = fn_body(source, "impl Render for CloseChoiceRememberState"); + assert!(contains_code(checkbox, &remember_label)); + assert!(contains_code(checkbox, "Checkbox::new")); + assert!(contains_code(checkbox, "cx.listener")); + + let minimize = fn_body(source, "fn minimize_main_window_to_tray"); + assert!(contains_code( + minimize, + "CloseButtonBehavior::MinimizeToTray" + )); + let quit = fn_body(source, "fn quit_from_close_choice_prompt"); + assert!(contains_code(quit, "CloseButtonBehavior::Quit")); + // 记住选择必须落盘,否则下次启动又被问一遍。 + let remember = fn_body(source, "fn remember_close_button_behavior"); + assert!(contains_code(remember, "AppSettings::update_and_save")); + assert!(contains_code( + remember, + "settings.close_button_behavior = behavior" + )); + } + + /// 隐藏失败必须回退到退出确认,不能留下一个用户找不到、也没法恢复的窗口。 + #[test] + fn hiding_to_the_tray_falls_back_to_quit_when_it_fails() { + let source = include_str!("navop_app.rs"); + let body = fn_body(source, "fn hide_main_window_to_tray"); + let hide = ["crate::window_visibility::", "hide_main_window(window)"].concat(); + + assert!(contains_code(body, &hide)); + assert!(contains_code(body, "Err(error) =>")); + assert!(contains_code(body, "self.request_quit(window, cx)")); + } + + /// 取一个顶层方法的完整函数体(从签名到下一个同缩进的 `\n }`)。 + fn fn_body<'a>(source: &'a str, signature: &str) -> &'a str { + let start = source + .find(signature) + .unwrap_or_else(|| panic!("{signature}")); + let end = source[start..] + .find("\n }\n") + .map(|offset| start + offset) + .unwrap_or_else(|| panic!("{signature} end")); + &source[start..end] + } + /// 源码守卫比对用:两侧都去掉全部空白再比对,避免 rustfmt 折行或补空格让断言 /// 静默失效(`(window, cx)` 这类参数列表也会被 rustfmt 重新折行)。 fn contains_code(source: &str, needle: &str) -> bool { @@ -2496,10 +2725,11 @@ impl Render for NavopApp { sidebar.render_docked_connection_tree(window, cx) }) }); - let floating_tree = (sidebar_expanded && auto_hide_tree && !home_has_navigation_sidebar).then(|| { - self.connection_sidebar - .update(cx, |sidebar, cx| sidebar.render_floating_tree(window, cx)) - }); + let floating_tree = (sidebar_expanded && auto_hide_tree && !home_has_navigation_sidebar) + .then(|| { + self.connection_sidebar + .update(cx, |sidebar, cx| sidebar.render_floating_tree(window, cx)) + }); // macOS:浮动树覆盖 tab 栏左侧,需要给红绿灯占位;停靠模式并排渲染则不需要。 #[cfg(target_os = "macos")] if sidebar_expanded { @@ -2570,9 +2800,10 @@ impl Render for NavopApp { .child(main_content), ) }) - .when(sidebar_expanded && auto_hide_tree && !home_has_navigation_sidebar, |this| { - this.when_some(floating_tree, |this, tree| this.child(tree)) - }) + .when( + sidebar_expanded && auto_hide_tree && !home_has_navigation_sidebar, + |this| this.when_some(floating_tree, |this, tree| this.child(tree)), + ) .children(sheet_layer) .children(dialog_layer) .children(notification_layer) diff --git a/main/src/setting_tab.rs b/main/src/setting_tab.rs index effaf5b01..cfd8b3bbe 100644 --- a/main/src/setting_tab.rs +++ b/main/src/setting_tab.rs @@ -66,10 +66,10 @@ const TEAM_KEYS_SETTINGS_PAGE_INDEX: usize = 6; use gpui_component::input::InputEvent; pub use one_core::settings::{ - AppSettings, CustomFont, DatabaseOpenMode, GlobalCurrentUser, GlobalProxySettings, LOCALE_EN, - LOCALE_SYSTEM, LOCALE_ZH_CN, LOCALE_ZH_HK, PersonalSyncBackendKind, PersonalSyncSettings, - ProxyType, SyncProvider, effective_locale_for_setting, is_installed_font_family, - is_supported_grid_monospace_font, + AppSettings, CloseButtonBehavior, CustomFont, DatabaseOpenMode, GlobalCurrentUser, + GlobalProxySettings, LOCALE_EN, LOCALE_SYSTEM, LOCALE_ZH_CN, LOCALE_ZH_HK, + PersonalSyncBackendKind, PersonalSyncSettings, ProxyType, SyncProvider, + effective_locale_for_setting, is_installed_font_family, is_supported_grid_monospace_font, }; use one_core::tab_container::{TabContent, TabContentEvent}; use one_core::utils::auto_save_config::AutoSaveConfig; @@ -632,6 +632,7 @@ impl SettingsPanel { .to_string(), ), ), + close_behavior_setting_group(default_settings.close_button_behavior), notes_setting_group(), SettingGroup::new() .title(t!("Settings.General.Appearance.group_title")) @@ -1008,6 +1009,44 @@ impl SettingsPanel { } } +/// 关闭主窗口时的行为。托盘不可用时该设置不生效,仍走退出确认。 +fn close_behavior_setting_group(default: CloseButtonBehavior) -> SettingGroup { + SettingGroup::new() + .title(t!("Settings.General.CloseBehavior.group_title")) + .item( + SettingItem::new( + t!("Settings.General.CloseBehavior.behavior"), + SettingField::dropdown( + vec![ + ( + SharedString::from(CloseButtonBehavior::Ask.as_str()), + t!("Settings.General.CloseBehavior.ask").into(), + ), + ( + SharedString::from(CloseButtonBehavior::MinimizeToTray.as_str()), + t!("Settings.General.CloseBehavior.minimize_to_tray").into(), + ), + ( + SharedString::from(CloseButtonBehavior::Quit.as_str()), + t!("Settings.General.CloseBehavior.quit").into(), + ), + ], + |cx: &App| { + SharedString::from(AppSettings::global(cx).close_button_behavior.as_str()) + }, + |val: SharedString, cx: &mut App| { + AppSettings::update_and_save(cx, |settings| { + settings.close_button_behavior = + CloseButtonBehavior::from_str(val.as_ref()); + }); + }, + ) + .default_value(SharedString::from(default.as_str())), + ) + .description(t!("Settings.General.CloseBehavior.behavior_desc").to_string()), + ) +} + fn sync_setting_group( sync_enabled_default: bool, sync_provider_default: SyncProvider, diff --git a/main/src/system_tray.rs b/main/src/system_tray.rs index 195462e29..68ae2f010 100644 --- a/main/src/system_tray.rs +++ b/main/src/system_tray.rs @@ -24,6 +24,7 @@ use anyhow::Context as _; use gpui::{AnyWindowHandle, App, AppContext as _, AsyncApp}; +use one_core::settings::CloseButtonBehavior; use one_core::tab_container::GlobalTabContainer; use std::sync::OnceLock; use std::sync::atomic::{AtomicBool, Ordering}; @@ -44,9 +45,11 @@ pub(crate) enum TrayCommand { /// 主窗口关闭按钮的行为。 #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum MainWindowCloseAction { - /// 托盘可用:隐藏窗口,保留进程、标签页和后台任务。 + /// 托盘可用但用户还没固化偏好:弹窗让用户当场选「最小化到托盘 / 退出应用」。 + AskUser, + /// 用户已选择最小化到托盘:隐藏窗口,保留进程、标签页和后台任务。 HideToTray, - /// 托盘不可用(或初始化/隐藏失败):走既有退出确认。 + /// 托盘不可用(或用户已选择退出):走既有退出确认。 RequestQuit, } @@ -61,13 +64,22 @@ pub(crate) const TRAY_SESSION_LIMIT: usize = 8; /// 会话标题最长保留的字符数,超出用省略号收尾。 pub(crate) const TRAY_SESSION_LABEL_MAX_CHARS: usize = 40; -/// 关闭按钮的纯策略:只有托盘确实可用时才隐藏窗口,否则必须回退到退出确认, +/// 关闭按钮的纯策略:只有托盘确实可用时才可能隐藏窗口,否则必须回退到退出确认, /// 不能制造一个无法恢复的隐藏窗口。 -pub(crate) const fn main_window_close_action(tray_ready: bool) -> MainWindowCloseAction { - if tray_ready { - MainWindowCloseAction::HideToTray - } else { - MainWindowCloseAction::RequestQuit +/// +/// 托盘可用时再按用户偏好细分:`Ask` 交回弹窗由用户当场选择(并可在弹窗里记住), +/// `MinimizeToTray` / `Quit` 是用户已经固化过的选择,不再打扰。 +pub(crate) const fn main_window_close_action( + tray_ready: bool, + behavior: CloseButtonBehavior, +) -> MainWindowCloseAction { + if !tray_ready { + return MainWindowCloseAction::RequestQuit; + } + match behavior { + CloseButtonBehavior::Ask => MainWindowCloseAction::AskUser, + CloseButtonBehavior::MinimizeToTray => MainWindowCloseAction::HideToTray, + CloseButtonBehavior::Quit => MainWindowCloseAction::RequestQuit, } } @@ -474,17 +486,36 @@ mod tests { use super::*; #[test] - fn close_action_hides_only_when_the_tray_is_ready() { + fn close_action_asks_the_user_until_a_choice_is_remembered() { + assert_eq!( + MainWindowCloseAction::AskUser, + main_window_close_action(true, CloseButtonBehavior::Ask) + ); assert_eq!( MainWindowCloseAction::HideToTray, - main_window_close_action(true) + main_window_close_action(true, CloseButtonBehavior::MinimizeToTray) ); assert_eq!( MainWindowCloseAction::RequestQuit, - main_window_close_action(false) + main_window_close_action(true, CloseButtonBehavior::Quit) ); } + #[test] + fn close_action_without_a_tray_never_hides_the_window() { + for behavior in [ + CloseButtonBehavior::Ask, + CloseButtonBehavior::MinimizeToTray, + CloseButtonBehavior::Quit, + ] { + assert_eq!( + MainWindowCloseAction::RequestQuit, + main_window_close_action(false, behavior), + "托盘不可用时任何偏好都不能隐藏窗口" + ); + } + } + #[test] fn menu_ids_map_to_the_minimal_command_set() { assert_eq!( diff --git a/main/src/windows_single_instance.rs b/main/src/windows_single_instance.rs index 5c55b689d..7105d397d 100644 --- a/main/src/windows_single_instance.rs +++ b/main/src/windows_single_instance.rs @@ -162,6 +162,25 @@ fn invalid_data(message: &'static str) -> io::Error { io::Error::new(io::ErrorKind::InvalidData, message) } +/// Windows 命名管道 `ERROR_ACCESS_DENIED`:interprocess 给监听器加了 +/// `FILE_FLAG_FIRST_PIPE_INSTANCE`,名字已存在时 `CreateNamedPipeW` 返回它。 +const WINDOWS_ERROR_ACCESS_DENIED: i32 = 5; +/// Windows 命名管道 `ERROR_PIPE_BUSY`:所有实例都在忙,同样说明名字已被占用。 +const WINDOWS_ERROR_PIPE_BUSY: i32 = 231; + +/// 创建监听器失败是否意味着「这个名字已经被占用」。 +/// +/// interprocess 的 Windows 分支会原样透传系统错误、不做 error kind 归一化, +/// 所以只看 `AddrInUse` 会漏掉真实的「已占用」,第二个实例遂误判成主实例、 +/// 起出两个完整进程。这里把两种形态都认下来。 +fn instance_name_taken(error: &io::Error) -> bool { + error.kind() == io::ErrorKind::AddrInUse + || matches!( + error.raw_os_error(), + Some(WINDOWS_ERROR_ACCESS_DENIED) | Some(WINDOWS_ERROR_PIPE_BUSY) + ) +} + #[cfg(target_os = "windows")] pub(crate) fn claim_or_forward( config_dir: &Path, @@ -198,7 +217,7 @@ pub(crate) fn claim_or_forward( })?; Ok(SingleInstanceOutcome::Primary) } - Err(error) if error.kind() == io::ErrorKind::AddrInUse => { + Err(error) if instance_name_taken(&error) => { forward_request::(&instance_name, &request)?; Ok(SingleInstanceOutcome::Forwarded) } @@ -322,4 +341,27 @@ mod tests { let oversized_payload = vec![0; MAX_PAYLOAD_BYTES + 1]; assert!(decode_request(&oversized_payload).is_err()); } + + #[test] + fn taken_instance_name_includes_windows_pipe_errors() { + // 名字已存在的两种真实形态:Windows 命名管道返回 ACCESS_DENIED / PIPE_BUSY, + // 其它平台返回 AddrInUse。 + assert!(instance_name_taken(&io::Error::new( + io::ErrorKind::AddrInUse, + "in use" + ))); + assert!(instance_name_taken(&io::Error::from_raw_os_error( + WINDOWS_ERROR_ACCESS_DENIED + ))); + assert!(instance_name_taken(&io::Error::from_raw_os_error( + WINDOWS_ERROR_PIPE_BUSY + ))); + + // 其它错误不能被当成「已占用」,否则主实例会把启动请求转发给不存在的管道。 + assert!(!instance_name_taken(&io::Error::new( + io::ErrorKind::PermissionDenied, + "denied" + ))); + assert!(!instance_name_taken(&io::Error::from_raw_os_error(2))); + } }