From ef2bd8c3a690b4b23f3c01ac79e1390e02cedd8d Mon Sep 17 00:00:00 2001 From: Xuz Date: Thu, 1 Oct 2026 21:00:22 +0800 Subject: [PATCH] =?UTF-8?q?macOS=20=E4=B8=8A=E7=82=B9=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E6=8C=89=E9=92=AE=E6=94=B9=E4=B8=BA=E6=8A=8A=20ShellRS=20?= =?UTF-8?q?=E9=9A=90=E8=97=8F=E5=88=B0=E5=90=8E=E5=8F=B0=EF=BC=9A=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E3=80=81=E4=BC=A0=E8=BE=93=E5=92=8C=E7=AB=AF=E5=8F=A3?= =?UTF-8?q?=E8=BD=AC=E5=8F=91=E7=85=A7=E5=B8=B8=E8=BF=90=E8=A1=8C=EF=BC=8C?= =?UTF-8?q?=E7=82=B9=20Dock=20=E5=9B=BE=E6=A0=87=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=EF=BC=8C=E2=8C=98Q=20=E6=89=8D=E9=80=80?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/windows.yml | 1 + CLAUDE.md | 1 + README.md | 2 + src/app/mod.rs | 2 + src/app/window_hiding.rs | 35 ++++++++++++++++++ src/main.rs | 65 ++++++++++++++++++--------------- src/workspace/workspace_view.rs | 3 +- 7 files changed, 77 insertions(+), 32 deletions(-) create mode 100644 src/app/window_hiding.rs diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index cc8f6f0..e184767 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -16,6 +16,7 @@ on: - "src/cli/**" - "src/update/**" - "src/app/paths.rs" + - "src/app/window_hiding.rs" - "src/main.rs" - "src/bin/**" - "src/ssh/connection.rs" diff --git a/CLAUDE.md b/CLAUDE.md index a6e63ec..fa8b2f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` 里启动)。 - 只要连得上就算「已在运行」,不管对方认不认识这个请求(旧版本会回 `bad_request`)、回不回话:第二个实例宁可悄悄退出,也不能在同一份数据上再跑一个。等回话放在单独的线程里并限时 2 秒,因为 Windows 的管道读不能设超时。 - 连不上(没在运行,或崩溃留下的套接字文件)才正常启动。两个同时启动、都没连上对方的话,后一个建套接字失败,只弹「外部 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` 时在数据目录下)。 - **发布走 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 下不能直接复制文件),备份失败只打日志、照样迁移。 diff --git a/README.md b/README.md index a83d635..da854ae 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已有的窗口调到前面,新启动的那个随即退出。两个 ShellRS 各自把主机读进内存再写回同一个数据库,会互相覆盖对方的改动。(用 `SHELLRS_DATA_DIR` 指到不同数据目录的算不同的 ShellRS,可以同时运行。)要并排看两个终端或 SFTP,把标签拖成分栏即可。 +在 macOS 上,点窗口左上角的关闭按钮只是把 ShellRS 隐藏到后台:已打开的连接、SFTP 传输、端口转发和外部 CLI 照常运行,点 Dock 图标恢复窗口,原来的标签都在。要真正退出用 ⌘Q,或者右键 Dock 图标选「退出」。Windows 和 Linux 上关闭窗口就是退出。 + ## 主机列表 左侧栏里保存的每一项是一台主机:地址、端口和认证方式。同一台机器用不同用户登录,就保存成两台主机。 diff --git a/src/app/mod.rs b/src/app/mod.rs index fe286f3..41c65b6 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -4,6 +4,7 @@ mod actions; mod app_icon; mod assets; mod paths; +mod window_hiding; pub use actions::*; pub use app_icon::show_logo_when_unbundled; @@ -11,6 +12,7 @@ pub use assets::{AppAssets, CatalogIcon}; pub use paths::{ 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::*; diff --git a/src/app/window_hiding.rs b/src/app/window_hiding.rs new file mode 100644 index 0000000..10a0faa --- /dev/null +++ b/src/app/window_hiding.rs @@ -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(); +} diff --git a/src/main.rs b/src/main.rs index 4ef499d..6f6dea5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,38 +22,43 @@ fn main() { if shellrs::cli::activate_running_app(&shellrs::app::cli_socket_path()) { return; } - gpui_kit::application() - .with_assets(shellrs::app::AppAssets) - .run(|cx| { - shellrs::init(cx); - shellrs::app::show_logo_when_unbundled(cx); - cx.activate(true); + let app = gpui_kit::application().with_assets(shellrs::app::AppAssets); + // macOS: the Dock icon of a ShellRS whose window was closed, which only + // hid it (see `hide_when_closed`). + app.on_reopen(|cx| { + if let Some(window) = cx.windows().first().copied() { + 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); - cx.spawn(async move |cx| { - cx.open_window(options, |window, cx| { - window.activate_window(); - window.set_window_title("ShellRS"); - let (store, problem) = open_store(); - let store = cx.new(|_| store); - let (settings, settings_problem) = open_settings(); - let settings = cx.new(|_| settings); - let workspace = cx - .new(|cx| shellrs::workspace::Workspace::new(store, settings, window, cx)); - // Not pushed here: the window has no `Root` to show it yet. - for problem in [problem, settings_problem].into_iter().flatten() { - shellrs::workspace::notify_once_open( - Notification::error(problem), - window, - cx, - ); - } - cx.new(|cx| Root::new(workspace, window, cx)) - }) - .expect("failed to open window"); + let options = shellrs::workspace::window_options(cx); + cx.spawn(async move |cx| { + cx.open_window(options, |window, cx| { + window.activate_window(); + window.set_window_title("ShellRS"); + shellrs::app::hide_when_closed(window, cx); + let (store, problem) = open_store(); + let store = cx.new(|_| store); + let (settings, settings_problem) = open_settings(); + let settings = cx.new(|_| settings); + let workspace = + cx.new(|cx| shellrs::workspace::Workspace::new(store, settings, window, cx)); + // Not pushed here: the window has no `Root` to show it yet. + for problem in [problem, settings_problem].into_iter().flatten() { + shellrs::workspace::notify_once_open(Notification::error(problem), window, cx); + } + cx.new(|cx| Root::new(workspace, window, cx)) }) - .detach(); - }); + .expect("failed to open window"); + }) + .detach(); + }); // 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. shellrs::update::start_handed_over(); diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index 28c72b1..5ae9343 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -251,8 +251,7 @@ impl Workspace { .as_ref() .is_some_and(|server| server.take_activation()); if asked { - cx.activate(true); - window.activate_window(); + crate::app::bring_forward(window, cx); } }); if open.is_err() {