Compare commits

...
12 changed files with 122 additions and 38 deletions

No files matched your search

+12 -1
View File
@@ -152,7 +152,18 @@ jobs:
cp -R build/ShellRS.app "$RUNNER_TEMP/dmg/"
ln -s /Applications "$RUNNER_TEMP/dmg/Applications"
dmg="dist/ShellRS-$VERSION-macos-universal.dmg"
hdiutil create -volname ShellRS -srcfolder "$RUNNER_TEMP/dmg" -ov -format UDZO "$dmg"
# hdiutil's own estimate of the image size once came out too small
# for the app ("No space left on device"), so the size is given,
# with room to spare; the compressed image is about half the app.
# It also fails now and then with "Resource busy" on the runners.
df -h "$RUNNER_TEMP" .
size=$(( $(du -sm "$RUNNER_TEMP/dmg" | cut -f1) * 2 + 20 ))
for attempt in 1 2 3; do
hdiutil create -volname ShellRS -srcfolder "$RUNNER_TEMP/dmg" -ov \
-fs HFS+ -size "${size}m" -format UDZO "$dmg" && break
[ "$attempt" = 3 ] && exit 1
sleep 10
done
codesign --force --timestamp --sign "$IDENTITY" "$dmg"
notarize "$dmg"
xcrun stapler staple "$dmg"
+23
View File
@@ -1,10 +1,33 @@
# The Windows build, which the Macs this is developed on cannot compile:
# the whole crate through clippy, the external CLI's tests, including the
# named pipe between two processes, and the updater's.
#
# A push runs it only when it touches code that differs on Windows (the
# files with `cfg(windows)` and friends), the build script or the
# dependencies. Windows code added to another file goes in this list too;
# until then, run it by hand (Actions › windows › Run workflow). Tags never
# run it: the release workflow builds Windows itself.
name: windows
on:
push:
branches: ["**"]
paths:
- "src/cli/**"
- "src/update/**"
- "src/app/paths.rs"
- "src/app/window_hiding.rs"
- "src/main.rs"
- "src/bin/**"
- "src/ssh/connection.rs"
- "src/sftp/download.rs"
- "src/credential/credential_dialog.rs"
- "src/settings/settings_panel.rs"
- "tests/cli_bin.rs"
- "build.rs"
- "Cargo.toml"
- "Cargo.lock"
- "rust-toolchain.toml"
workflow_dispatch:
jobs:
+8 -1
View File
@@ -17,7 +17,14 @@ ShellRS 的第一个公开版本。
- 外部 CLI:`shellrs` 命令让 AI Agent 用已保存的主机执行命令、传输文件。
- 在线升级:在后台检查并下载新版本,下好后标题栏右上角提示,重启或退出时安装。
## [0.1.0-beta.1]
## [0.1.0-beta.3]
### 改进
- macOS:点窗口左上角的关闭按钮,现在会把 ShellRS 隐藏到后台,已打开的连接、SFTP 传输和端口转发照常运行;点 Dock 图标就能把窗口找回来,原来的标签都在。要退出请用 ⌘Q,或右键 Dock 图标选「退出」。以前关掉窗口后,ShellRS 留在 Dock 里却打不开窗口。
- 开始页没有连接记录时,去掉了一句重复的提示。
## [0.1.0-beta.2]
ShellRS 的第一个测试版本,只发到 Beta 渠道,用来验证安装和在线升级。之后的测试版会通过在线升级自动送达。
+2 -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` 里启动)。
- 只要连得上就算「已在运行」,不管对方认不认识这个请求(旧版本会回 `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 下不能直接复制文件),备份失败只打日志、照样迁移。
@@ -132,7 +133,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- 用同步的 Win32 管道,不用 tokio 的:请求线程要阻塞的 `File`,而 tokio 的管道是 overlapped 句柄,拿去做同步 IO 时 std 会中止进程。
- 监听线程先建好下一个实例再把已连接的交给请求线程,否则那一刻来的客户端会看到「没运行」。请求线程结束时直接关句柄,**不要** `DisconnectNamedPipe`(会丢掉客户端还没读的数据)也不要 `FlushFileBuffers`(客户端挂住时会一直阻塞)。停止时置标志、反复以客户端身份连一下唤醒 `ConnectNamedPipe`,直到线程结束。
- **Windows 的 PATH 是副本加用户 PATH。** `IntegrationPaths::system()` 在 Windows 上:`exe` 是 `shellrs.exe` 旁边的 `shellrs-cli.exe`,`bin_link` 是 `%LOCALAPPDATA%\ShellRS\bin\shellrs.exe`(副本,不是链接),`user_path` 是 `UserPath::Registry`(`HKCU\Environment\Path`,写回 `REG_EXPAND_SZ` 后广播 `WM_SETTINGCHANGE`;只有之后启动的程序看得到)。副本按长度加修改时间判断是否本版(安装时用 `set_modified` 把时间设成源文件的)。正在运行的副本不能覆盖但能改名,所以安装、移除都先把旧的改名成 `shellrs.exe.<uuid>.old` 再尽量删掉。生产环境启动时 `CliIntegration::update_outdated` 在后台更新旧副本,否则 Agent 会一直碰到 `version_mismatch`。测试一律 `user_path: None` 或 `UserPath::File`,绝不碰注册表。开发时 `cargo run` 只构建 `shellrs`,要 `cargo build` 才有 `shellrs-cli.exe`,否则设置页显示找不到。
- **Windows 只在 CI 上编译和测试**(`.github/workflows/windows.yml`:clippy 全 crate、`cargo test --lib cli::`、`cargo test --test cli_bin`),本机没有 Windows 工具链;改 `pipe_windows.rs`、`install_windows.rs` 的 Windows 部分和 `cfg(windows)` 代码后要看 CI。
- **Windows 只在 CI 上编译和测试**(`.github/workflows/windows.yml`:clippy 全 crate、`cargo test --lib cli::`、`cargo test --lib update::`、`cargo test --test cli_bin`),本机没有 Windows 工具链;改 `pipe_windows.rs`、`install_windows.rs` 的 Windows 部分和 `cfg(windows)` 代码后要看 CI。它不是每次推送都跑:只在推送改到 `paths` 里列的文件(含 `cfg(windows)` 等平台代码的文件、`build.rs`、`Cargo.toml` / `Cargo.lock`)时才跑,tag 不触发;在别的文件里新加 Windows 代码时把文件加进这个列表,或者在 Actions 里手动运行。
- **每个请求一个线程**,后端方法都是阻塞的;SSH 句柄绑在创建它的 runtime 上,所以 `ssh::run_command` 和 SFTP 工作线程各自建 current-thread runtime。
- **exec 不许挂住**:`SshPrompts` 非交互;陌生主机在 events 回调里直接返回 `false`(`ask` 没人回答会永远等下去,因为超时不计提示时间),缺凭据由 `MissingCredential` 失败。`exec(true)` 后立刻 `eof()`,远程没有 stdin;读循环读到 `Close` / `None` 为止,退出码可能在 EOF 之后才到。输出回调写失败(Agent 断开)就放弃命令、断开连接。
- **传输无人值守**:`SshCliBackend::transfer` 先等 `Connected` 再发请求;连接前的 `Prompt` 一律回 `Cancel` 并按提示种类给出错误码;`Question` 按固定策略回答(冲突覆盖并勾选全部、续传一律重新开始、出错跳过并记为失败);以 `Idle` 为结束。请求用 `UploadRequest::scp` / `DownloadRequest::scp`:worker 连上后按 scp 规则解析目标(`scp_remote_target` / `scp_local_target`,目标目录必须存在,远程还要 `stat` 确认,因为有的服务器 realpath 不存在的路径也成功),并让新建文件保留源文件的权限位(上传整套 `0o777`,下载只补可执行位);SFTP 标签的请求不受影响。批次准备失败时 worker 也发 `Idle`。
Generated
+1 -1
View File
@@ -7686,7 +7686,7 @@ dependencies = [
[[package]]
name = "shellrs"
version = "0.1.0-beta.1"
version = "0.1.0-beta.3"
dependencies = [
"alacritty_terminal",
"anyhow",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "shellrs"
version = "0.1.0-beta.1"
version = "0.1.0-beta.3"
edition = "2024"
publish = false
description = "SSH session manager with local terminal support"
+2
View File
@@ -6,6 +6,8 @@
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 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::*;
+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()) {
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();
-1
View File
@@ -119,7 +119,6 @@ impl RecentHosts {
.text_color(muted)
.child(Icon::new(CatalogIcon::Server).large())
.child(div().text_sm().child("还没有连接过的主机"))
.child(div().text_xs().child("双击左侧的主机即可连接"))
}
fn on_connect_selected(
+1 -2
View File
@@ -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() {