mirror of
https://github.com/feigeCode/navop.git
synced 2026-09-22 16:01:30 +00:00
feat(tray): 关闭按钮询问「最小化到托盘 / 退出应用」,并修复 Windows 重复启动
关闭行为从「托盘可用就隐藏」改成先看用户偏好:
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 重复启动与弹窗交互仍需真机冒烟。
This commit is contained in:
@@ -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<MainWindowSize>,
|
||||
#[serde(default)]
|
||||
pub main_window_state: Option<MainWindowState>,
|
||||
#[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);
|
||||
|
||||
@@ -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 品牌资源;
|
||||
|
||||
@@ -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:
|
||||
|
||||
+253
-22
@@ -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<TabContainer>,
|
||||
connection_sidebar: Entity<PersistentConnectionSidebar>,
|
||||
quit_state: QuitRequestState,
|
||||
/// 「最小化到托盘 / 退出应用」弹窗是否已经打开:标题栏关闭按钮可能被连点,
|
||||
/// 不能叠出第二个完全相同的弹窗。
|
||||
close_choice_prompt_open: bool,
|
||||
main_window_size_save_task: Option<Task<()>>,
|
||||
_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<Self>) -> 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>) -> 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<Self>) {
|
||||
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>,
|
||||
) {
|
||||
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>,
|
||||
) {
|
||||
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<Self>,
|
||||
) {
|
||||
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<Self>) {
|
||||
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>) {
|
||||
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)
|
||||
|
||||
+43
-4
@@ -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,
|
||||
|
||||
+42
-11
@@ -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!(
|
||||
|
||||
@@ -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::<Stream>(&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)));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user