Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fa7fefeb0 | ||
|
|
75a0e76e17 | ||
|
|
ef2bd8c3a6 | ||
|
|
bcf2db0413 |
No files matched your search
@@ -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:
|
||||
|
||||
@@ -17,6 +17,13 @@ ShellRS 的第一个公开版本。
|
||||
- 外部 CLI:`shellrs` 命令让 AI Agent 用已保存的主机执行命令、传输文件。
|
||||
- 在线升级:在后台检查并下载新版本,下好后标题栏右上角提示,重启或退出时安装。
|
||||
|
||||
## [0.1.0-beta.3]
|
||||
|
||||
### 改进
|
||||
|
||||
- macOS:点窗口左上角的关闭按钮,现在会把 ShellRS 隐藏到后台,已打开的连接、SFTP 传输和端口转发照常运行;点 Dock 图标就能把窗口找回来,原来的标签都在。要退出请用 ⌘Q,或右键 Dock 图标选「退出」。以前关掉窗口后,ShellRS 留在 Dock 里却打不开窗口。
|
||||
- 开始页没有连接记录时,去掉了一句重复的提示。
|
||||
|
||||
## [0.1.0-beta.2]
|
||||
|
||||
ShellRS 的第一个测试版本,只发到 Beta 渠道,用来验证安装和在线升级。之后的测试版会通过在线升级自动送达。
|
||||
|
||||
@@ -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
@@ -7686,7 +7686,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shellrs"
|
||||
version = "0.1.0-beta.2"
|
||||
version = "0.1.0-beta.3"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "shellrs"
|
||||
version = "0.1.0-beta.2"
|
||||
version = "0.1.0-beta.3"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
description = "SSH session manager with local terminal support"
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已有的窗口调到前面,新启动的那个随即退出。两个 ShellRS 各自把主机读进内存再写回同一个数据库,会互相覆盖对方的改动。(用 `SHELLRS_DATA_DIR` 指到不同数据目录的算不同的 ShellRS,可以同时运行。)要并排看两个终端或 SFTP,把标签拖成分栏即可。
|
||||
|
||||
在 macOS 上,点窗口左上角的关闭按钮只是把 ShellRS 隐藏到后台:已打开的连接、SFTP 传输、端口转发和外部 CLI 照常运行,点 Dock 图标恢复窗口,原来的标签都在。要真正退出用 ⌘Q,或者右键 Dock 图标选「退出」。Windows 和 Linux 上关闭窗口就是退出。
|
||||
|
||||
## 主机列表
|
||||
|
||||
左侧栏里保存的每一项是一台主机:地址、端口和认证方式。同一台机器用不同用户登录,就保存成两台主机。
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user