macOS 上点关闭按钮改为把 ShellRS 隐藏到后台:连接、传输和端口转发照常运行,点 Dock 图标恢复窗口,⌘Q 才退出

This commit is contained in:
Xuz committed 2026-10-01 21:00:22 +08:00
1 parent bcf2db0413
commit ef2bd8c3a6
7 files changed
+77 -32

No files matched your search

+1
View File
@@ -16,6 +16,7 @@ on:
- "src/cli/**" - "src/cli/**"
- "src/update/**" - "src/update/**"
- "src/app/paths.rs" - "src/app/paths.rs"
- "src/app/window_hiding.rs"
- "src/main.rs" - "src/main.rs"
- "src/bin/**" - "src/bin/**"
- "src/ssh/connection.rs" - "src/ssh/connection.rs"
+1
View File
@@ -117,6 +117,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- 服务端在版本检查和「启用外部 CLI」开关**之前**就回应它(这不是外部 CLI 的能力,只是把窗口调到前面),把 `Shared::activation` 置位;请求线程够不着窗口,工作区每 200 ms 问一次 `CliServer::take_activation()`,为真就 `cx.activate(true)` 加 `window.activate_window()`(`come_forward_when_opened_again`,只在生产路径 `Workspace::new` 里启动)。 - 服务端在版本检查和「启用外部 CLI」开关**之前**就回应它(这不是外部 CLI 的能力,只是把窗口调到前面),把 `Shared::activation` 置位;请求线程够不着窗口,工作区每 200 ms 问一次 `CliServer::take_activation()`,为真就 `cx.activate(true)` 加 `window.activate_window()`(`come_forward_when_opened_again`,只在生产路径 `Workspace::new` 里启动)。
- 只要连得上就算「已在运行」,不管对方认不认识这个请求(旧版本会回 `bad_request`)、回不回话:第二个实例宁可悄悄退出,也不能在同一份数据上再跑一个。等回话放在单独的线程里并限时 2 秒,因为 Windows 的管道读不能设超时。 - 只要连得上就算「已在运行」,不管对方认不认识这个请求(旧版本会回 `bad_request`)、回不回话:第二个实例宁可悄悄退出,也不能在同一份数据上再跑一个。等回话放在单独的线程里并限时 2 秒,因为 Windows 的管道读不能设超时。
- 连不上(没在运行,或崩溃留下的套接字文件)才正常启动。两个同时启动、都没连上对方的话,后一个建套接字失败,只弹「外部 CLI 无法启动」。不做多窗口:并排看靠 Dock 分栏;将来要做,共享的数据和服务得先提到应用层。 - 连不上(没在运行,或崩溃留下的套接字文件)才正常启动。两个同时启动、都没连上对方的话,后一个建套接字失败,只弹「外部 CLI 无法启动」。不做多窗口:并排看靠 Dock 分栏;将来要做,共享的数据和服务得先提到应用层。
- **macOS 上关窗口是隐藏,不是退出。** ShellRS 只有一个窗口,在跑的东西(连接、传输、端口转发、外部 CLI)都归窗口里的工作区;GPUI 在 macOS 上默认不随最后一个窗口退出(`QuitMode::Default`),真关掉窗口会停掉这一切,却留下一个没有窗口、点 Dock 图标也唤不回的进程。所以 `main` 给窗口挂 `app::hide_when_closed`(`on_window_should_close` 里 `cx.hide()` 并返回 `false`),点 Dock 图标(`Application::on_reopen`)和再次打开 ShellRS(`come_forward_when_opened_again`)都走 `app::bring_forward`:先 `NSApplication::unhide`(只 `activate` 不一定让隐藏的窗口重新出现),再激活窗口。⌘Q 才退出。Windows / Linux 关窗口即退出(GPUI 的默认)。测试平台的 `hide()` 是 `unimplemented!()`,所以这条只挂在生产路径上,没有 UI 测试,改动后要在真实窗口里试。
- **在线升级的接入照外部 CLI 的先例。** `new_with_services` 只建一个没有服务的 `Updater`(阶段 `Off`、从不联网),生产路径 `Workspace::new` 再 `set_services(UpdateServices::system(cx.app_path()))` 和 `start()`;UI 测试经 `workspace.updater()` 注入假的 feed、安装器、测试公钥和临时目录。「自动升级」和「更新渠道」存在 `AppSettings::update`(`automatic`、`channel`,后者直接用 `update::Channel`,在 settings 里实现 `Choice`),工作区在 `observe_in(&settings)` 里 `sync_updater`(`set_automatic`、`set_channel`),`update` 模块不依赖 `settings`。动作是 `CheckForUpdates` / `DownloadUpdate` / `ShowUpdate` / `RestartToUpdate` / `OpenDownloadPage`,处理器在 `workspace/updates.rs`;对话框里「重启会中断什么」由工作区数(`restart_impact`:运行中的远程终端、已连接的 SFTP 标签、本地终端、传输、端口转发)。重启走 gpui 的 `cx.set_restart_path` + `cx.restart()`,它会照常跑 `on_app_quit`(SFTP 借此保存续传进度);脚本没能起来时 2 秒后报失败。**Windows 不在退出过程中起进程**:gpui 退出时在 App 仍被借用的状态下跑 `on_app_quit`,而创建进程会泵消息循环、重入 GPUI,所以安装程序的等待进程交给 `update::start_handed_over()`,由 `main` 在 `run()` 返回之后启动(Windows 的事件循环结束后 `run` 会返回)。更新前写 `updates/applied.json`(from、to),新进程据此通知「已更新到」或「更新没有完成」。下载目录是 `app::updates_dir()`(用户缓存目录;设了 `SHELLRS_DATA_DIR` 时在数据目录下)。 - **在线升级的接入照外部 CLI 的先例。** `new_with_services` 只建一个没有服务的 `Updater`(阶段 `Off`、从不联网),生产路径 `Workspace::new` 再 `set_services(UpdateServices::system(cx.app_path()))` 和 `start()`;UI 测试经 `workspace.updater()` 注入假的 feed、安装器、测试公钥和临时目录。「自动升级」和「更新渠道」存在 `AppSettings::update`(`automatic`、`channel`,后者直接用 `update::Channel`,在 settings 里实现 `Choice`),工作区在 `observe_in(&settings)` 里 `sync_updater`(`set_automatic`、`set_channel`),`update` 模块不依赖 `settings`。动作是 `CheckForUpdates` / `DownloadUpdate` / `ShowUpdate` / `RestartToUpdate` / `OpenDownloadPage`,处理器在 `workspace/updates.rs`;对话框里「重启会中断什么」由工作区数(`restart_impact`:运行中的远程终端、已连接的 SFTP 标签、本地终端、传输、端口转发)。重启走 gpui 的 `cx.set_restart_path` + `cx.restart()`,它会照常跑 `on_app_quit`(SFTP 借此保存续传进度);脚本没能起来时 2 秒后报失败。**Windows 不在退出过程中起进程**:gpui 退出时在 App 仍被借用的状态下跑 `on_app_quit`,而创建进程会泵消息循环、重入 GPUI,所以安装程序的等待进程交给 `update::start_handed_over()`,由 `main` 在 `run()` 返回之后启动(Windows 的事件循环结束后 `run` 会返回)。更新前写 `updates/applied.json`(from、to),新进程据此通知「已更新到」或「更新没有完成」。下载目录是 `app::updates_dir()`(用户缓存目录;设了 `SHELLRS_DATA_DIR` 时在数据目录下)。
- **发布走 tag。** 推 `v<版本>`(和 `Cargo.toml` 一致,`CHANGELOG.md` 里要有 `## [版本]` 一节)触发 `.github/workflows/release.yml`:分平台构建打包(`packaging/macos/bundle.sh`、`packaging/windows/shellrs.iss`、`packaging/linux/appimage.sh`),在需要人工批准的 `release` 环境里上传 R2、建 GitHub Release,**最后**才用 `packaging/make-manifest.sh` 生成并上传清单、清 CDN 缓存。清单格式见 `packaging/manifest.example.json`(`the_example_manifest_parses` 守着它和解析器一致,`the_release_script_signs_what_the_client_accepts` 守着脚本和验签一致)。一次性准备和 secrets 见 `README.md` 的「发布」。 - **发布走 tag。** 推 `v<版本>`(和 `Cargo.toml` 一致,`CHANGELOG.md` 里要有 `## [版本]` 一节)触发 `.github/workflows/release.yml`:分平台构建打包(`packaging/macos/bundle.sh`、`packaging/windows/shellrs.iss`、`packaging/linux/appimage.sh`),在需要人工批准的 `release` 环境里上传 R2、建 GitHub Release,**最后**才用 `packaging/make-manifest.sh` 生成并上传清单、清 CDN 缓存。清单格式见 `packaging/manifest.example.json`(`the_example_manifest_parses` 守着它和解析器一致,`the_release_script_signs_what_the_client_accepts` 守着脚本和验签一致)。一次性准备和 secrets 见 `README.md` 的「发布」。
- **库只往前升,旧版本不碰新库。** `migrate` 遇到 `user_version > SCHEMA_VERSION`(或早于第一步)直接报错、不动文件(`open_store` 退回内存 store 并提示);迁移已有的库之前先 `VACUUM INTO '<库>.v<旧版本>.bak'`(WAL 下不能直接复制文件),备份失败只打日志、照样迁移。 - **库只往前升,旧版本不碰新库。** `migrate` 遇到 `user_version > SCHEMA_VERSION`(或早于第一步)直接报错、不动文件(`open_store` 退回内存 store 并提示);迁移已有的库之前先 `VACUUM INTO '<库>.v<旧版本>.bak'`(WAL 下不能直接复制文件),备份失败只打日志、照样迁移。
+2
View File
@@ -6,6 +6,8 @@
ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已有的窗口调到前面,新启动的那个随即退出。两个 ShellRS 各自把主机读进内存再写回同一个数据库,会互相覆盖对方的改动。(用 `SHELLRS_DATA_DIR` 指到不同数据目录的算不同的 ShellRS,可以同时运行。)要并排看两个终端或 SFTP,把标签拖成分栏即可。 ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已有的窗口调到前面,新启动的那个随即退出。两个 ShellRS 各自把主机读进内存再写回同一个数据库,会互相覆盖对方的改动。(用 `SHELLRS_DATA_DIR` 指到不同数据目录的算不同的 ShellRS,可以同时运行。)要并排看两个终端或 SFTP,把标签拖成分栏即可。
在 macOS 上,点窗口左上角的关闭按钮只是把 ShellRS 隐藏到后台:已打开的连接、SFTP 传输、端口转发和外部 CLI 照常运行,点 Dock 图标恢复窗口,原来的标签都在。要真正退出用 ⌘Q,或者右键 Dock 图标选「退出」。Windows 和 Linux 上关闭窗口就是退出。
## 主机列表 ## 主机列表
左侧栏里保存的每一项是一台主机:地址、端口和认证方式。同一台机器用不同用户登录,就保存成两台主机。 左侧栏里保存的每一项是一台主机:地址、端口和认证方式。同一台机器用不同用户登录,就保存成两台主机。
+2
View File
@@ -4,6 +4,7 @@ mod actions;
mod app_icon; mod app_icon;
mod assets; mod assets;
mod paths; mod paths;
mod window_hiding;
pub use actions::*; pub use actions::*;
pub use app_icon::show_logo_when_unbundled; pub use app_icon::show_logo_when_unbundled;
@@ -11,6 +12,7 @@ pub use assets::{AppAssets, CatalogIcon};
pub use paths::{ pub use paths::{
cli_endpoint, cli_socket_path, data_dir, database_path, keys_dir, settings_path, updates_dir, cli_endpoint, cli_socket_path, data_dir, database_path, keys_dir, settings_path, updates_dir,
}; };
pub use window_hiding::{bring_forward, hide_when_closed};
use gpui_kit::component::{Theme, dock::ToggleZoom}; use gpui_kit::component::{Theme, dock::ToggleZoom};
use gpui_kit::*; use gpui_kit::*;
+35
View File
@@ -0,0 +1,35 @@
//! On macOS the window's close button hides ShellRS instead of closing it.
//!
//! ShellRS has one window, and the workspace in it owns everything that
//! runs: connections, transfers, port forwards, the external CLI. Closing
//! it would stop all of that yet leave the app in the Dock with no window,
//! and the Dock icon could not bring one back. So, like Mac terminals,
//! closing hides the app: the Dock icon, ⌘Tab or opening ShellRS again
//! shows the window as it was, and ⌘Q quits. Windows and Linux quit when
//! the window closes, as GPUI does there by default.
use gpui_kit::{App, Window};
/// Make the window's close button hide ShellRS. Nothing outside macOS.
#[cfg(target_os = "macos")]
pub fn hide_when_closed(window: &Window, cx: &App) {
window.on_window_should_close(cx, |_, cx| {
cx.hide();
false
});
}
#[cfg(not(target_os = "macos"))]
pub fn hide_when_closed(_: &Window, _: &App) {}
/// Show the window again, in front of other apps: when the Dock icon is
/// clicked, or ShellRS is opened again while it runs.
pub fn bring_forward(window: &mut Window, cx: &mut App) {
// Activating alone may leave a hidden app's windows hidden.
#[cfg(target_os = "macos")]
if let Some(main_thread) = objc2::MainThreadMarker::new() {
objc2_app_kit::NSApplication::sharedApplication(main_thread).unhide(None);
}
cx.activate(true);
window.activate_window();
}
+35 -30
View File
@@ -22,38 +22,43 @@ fn main() {
if shellrs::cli::activate_running_app(&shellrs::app::cli_socket_path()) { if shellrs::cli::activate_running_app(&shellrs::app::cli_socket_path()) {
return; return;
} }
gpui_kit::application() let app = gpui_kit::application().with_assets(shellrs::app::AppAssets);
.with_assets(shellrs::app::AppAssets) // macOS: the Dock icon of a ShellRS whose window was closed, which only
.run(|cx| { // hid it (see `hide_when_closed`).
shellrs::init(cx); app.on_reopen(|cx| {
shellrs::app::show_logo_when_unbundled(cx); if let Some(window) = cx.windows().first().copied() {
cx.activate(true); window
.update(cx, |_, window, cx| shellrs::app::bring_forward(window, cx))
.ok();
}
});
app.run(|cx| {
shellrs::init(cx);
shellrs::app::show_logo_when_unbundled(cx);
cx.activate(true);
let options = shellrs::workspace::window_options(cx); let options = shellrs::workspace::window_options(cx);
cx.spawn(async move |cx| { cx.spawn(async move |cx| {
cx.open_window(options, |window, cx| { cx.open_window(options, |window, cx| {
window.activate_window(); window.activate_window();
window.set_window_title("ShellRS"); window.set_window_title("ShellRS");
let (store, problem) = open_store(); shellrs::app::hide_when_closed(window, cx);
let store = cx.new(|_| store); let (store, problem) = open_store();
let (settings, settings_problem) = open_settings(); let store = cx.new(|_| store);
let settings = cx.new(|_| settings); let (settings, settings_problem) = open_settings();
let workspace = cx let settings = cx.new(|_| settings);
.new(|cx| shellrs::workspace::Workspace::new(store, settings, window, cx)); let workspace =
// Not pushed here: the window has no `Root` to show it yet. cx.new(|cx| shellrs::workspace::Workspace::new(store, settings, window, cx));
for problem in [problem, settings_problem].into_iter().flatten() { // Not pushed here: the window has no `Root` to show it yet.
shellrs::workspace::notify_once_open( for problem in [problem, settings_problem].into_iter().flatten() {
Notification::error(problem), shellrs::workspace::notify_once_open(Notification::error(problem), window, cx);
window, }
cx, cx.new(|cx| Root::new(workspace, window, cx))
);
}
cx.new(|cx| Root::new(workspace, window, cx))
})
.expect("failed to open window");
}) })
.detach(); .expect("failed to open window");
}); })
.detach();
});
// On Windows the event loop ends and `run` returns: an update that is // On Windows the event loop ends and `run` returns: an update that is
// to be installed now starts its setup, which waits for this process. // to be installed now starts its setup, which waits for this process.
shellrs::update::start_handed_over(); shellrs::update::start_handed_over();
+1 -2
View File
@@ -251,8 +251,7 @@ impl Workspace {
.as_ref() .as_ref()
.is_some_and(|server| server.take_activation()); .is_some_and(|server| server.take_activation());
if asked { if asked {
cx.activate(true); crate::app::bring_forward(window, cx);
window.activate_window();
} }
}); });
if open.is_err() { if open.is_err() {