Compare commits

...
Author SHA1 Message Date
Xuz 7fa7fefeb0 版本号改为 0.1.0-beta.3 2026-10-01 21:02:31 +08:00
Xuz 75a0e76e17 开始页没有连接记录时去掉「双击左侧的主机即可连接」:标题下面已经写了 2026-10-01 21:00:22 +08:00
Xuz ef2bd8c3a6 macOS 上点关闭按钮改为把 ShellRS 隐藏到后台:连接、传输和端口转发照常运行,点 Dock 图标恢复窗口,⌘Q 才退出 2026-10-01 21:00:22 +08:00
Xuz bcf2db0413 Windows CI 只在推送改到平台相关代码、构建脚本或依赖时运行,tag 不再触发 2026-10-01 20:12:03 +08:00
Xuz e1a74faa53 版本号改为 0.1.0-beta.2:beta.1 打包失败、没有发布,第一个测试版改用这个版本号 2026-10-01 20:08:02 +08:00
Xuz a9a7e7087f 发布流程:建 DMG 时显式给出映像大小和 HFS+ 文件系统,失败重试;hdiutil 自己估的大小装不下应用 2026-10-01 19:59:33 +08:00
Xuz 0fbcb7adbf 版本号改为 0.1.0-beta.1,准备第一个测试版 2026-10-01 19:34:32 +08:00
Xuz 43b5f81d51 README 去掉 SHELLRS_RELEASE_BASE 的说明:make-manifest.sh 里没有这个变量 2026-10-01 19:05:04 +08:00
Xuz 533d7aa3a1 更新清单里给每个包加上 GitHub Release 作为第二个下载地址 2026-10-01 19:04:15 +08:00
Xuz ee6633c401 make-manifest.sh 可用 SHELLRS_RELEASE_BASE 把下载地址指向本地服务器,便于本机演练升级 2026-10-01 17:32:09 +08:00
Xuz 1f330212e8 填入在线升级的发布公钥:日常签名用的一把和离线备用的一把 2026-10-01 17:32:09 +08:00
15 changed files with 179 additions and 49 deletions

No files matched your search

+19 -2
View File
@@ -6,7 +6,9 @@
# linux AppImage
# publish (environment `release`, approved by hand) uploads to R2 behind
# dl.shellrs.com, creates the GitHub release, and only then
# replaces the channel's manifest, which is what clients follow
# replaces the channel's manifest, which is what clients follow;
# the manifest lists the GitHub release as each package's
# second address
#
# A tag with a pre-release (v0.3.0-beta.1) goes to the beta channel only. A
# stable release also becomes the beta channel's, unless beta is ahead.
@@ -150,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"
@@ -284,6 +297,10 @@ jobs:
- name: Manifests, last
env:
MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }}
# The GitHub release just created: clients try it when
# dl.shellrs.com fails. The repository is public, so it needs no
# sign-in.
SHELLRS_MIRROR_BASE: ${{ github.server_url }}/${{ github.repository }}/releases/download/v${{ needs.verify.outputs.version }}
run: |
publish() {
packaging/make-manifest.sh "$VERSION" "$1" notes.md dist "$1.json"
+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:
+22
View File
@@ -16,3 +16,25 @@ ShellRS 的第一个公开版本。
- 端口转发:本地、远程和动态转发,断线后自动重连,可随 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 渠道,用来验证安装和在线升级。之后的测试版会通过在线升级自动送达。
### 新增
- 主机与分组:持久保存,分组可以多层嵌套,支持搜索和拖动整理。
- 凭据:密码、密钥(本机文件、粘贴或生成新密钥)和 SSH Agent,多台主机可以共用一条凭据。
- 连接方式:直接连接、SSH 跳板或 HTTP / SOCKS5 代理。
- 终端:本地终端和多个 SSH 终端,支持查找、清屏和延迟显示。
- SFTP:对标 WinSCP 的双栏文件浏览器,上传和下载都支持断点续传和传输队列。
- 端口转发:本地、远程和动态转发,断线后自动重连,可随 ShellRS 一起启动。
- 外部 CLI:`shellrs` 命令让 AI Agent 用已保存的主机执行命令、传输文件。
- 在线升级:在后台检查并下载新版本,下好后标题栏右上角提示,重启或退出时安装。
+3 -2
View File
@@ -10,7 +10,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP
主机和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传,并按 WinSCP 的方式排队(每个 SFTP 标签一个传输队列);目录同步尚未实现。SSH 端口转发(本地 `-L`、远程 `-R`、动态 `-D`)也是真的,规则和主机存在同一个库里。凭据(密码 / 密钥 / SSH Agent)也存在这个库里(密码和口令照旧只进钥匙串,粘贴或生成的私钥是数据目录 `keys/` 下的文件),主机可以「使用凭据」而不是自己填登录信息。主机还有「连接方式」:直接连接、经过别的主机做 SSH 跳板(可多级)、或经由 HTTP / SOCKS5 代理,终端、SFTP、端口转发、外部 CLI 和测试连接都照它走。详见 `README.md`。
已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一主机可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或主机;分组支持任意层级嵌套,主机也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的主机一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的三个图标(主机 / 端口转发 / 凭据)切换;一条规则经由一个已保存的主机,启动时自己建一条 SSH 连接,不计入主机的连接状态,主机「断开连接」不停它;删除主机(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。凭据是侧栏的第三个列表(密码 / 密钥 / SSH Agent 三种),**凭据带用户名**:主机「使用凭据」时不再填用户名,改凭据的用户名所有用它的主机一起变;主机对话框的「认证方式」是三选一:「密码」(只有用户名和密码,密码留空就每次连接时询问,不悄悄试密钥)、「使用凭据」和「无密码」(只有用户名:依次试服务器免认证、SSH Agent 和 `~/.ssh` 的默认私钥,服务器要求密码时直接报错、不询问;密钥之后再要验证码照样问);主机自己不再有私钥文件,要用私钥就建密钥凭据;密钥凭据的私钥有三种来源——「本机文件」(只引用路径,不导入内容)、「粘贴」和「生成新密钥」(Ed25519 默认,另有 RSA 4096)——在同一个对话框里(类型选「密钥」后多一排来源单选),不按类型拆对话框,因为编辑时要能改类型、换钥匙,用它的主机一起跟着变;粘贴和生成的私钥由 ShellRS 写成数据目录 `keys/` 下的文件(不进钥匙串:Windows 凭据管理器一条最多 2560 字节,RSA 私钥放不下),凭据引用它的路径,编辑时就显示为「本机文件」;空列表和空白处的右键菜单多一个「生成密钥…」,打开的是同一个对话框、已选好「密钥 · 生成新密钥」,密钥凭据的右键菜单有「复制公钥」;删除仍在用的凭据要确认并写明台数,删除后那些主机保留用户名,密码凭据的改成「密码」(连接时询问),密钥和 Agent 凭据的改成「无密码」(`CredentialKind::without_credential`),密码和私钥不复制过去,已打开的连接不断开;主机对话框里不做「新建凭据…」(设计规范不叠弹层);Windows 上 Agent 走 OpenSSH Agent 的命名管道,「无密码」也试它;暂不做自定义 Agent 套接字、Pageant、凭据的外部 CLI 接口和凭据排序。主机对话框「分组」下面是「连接方式」三选一:「直接连接」(默认)、「SSH 跳板」和「代理连接」。跳板选的是已保存的主机,可多级,**只走这里列出的链**:跳板主机自己的连接方式不参与(所以不会成环),每台用它自己的地址、端口、认证和 known_hosts 条目;跳板列表不做拖动排序(删了重加)。代理是 HTTP(`CONNECT`)或 SOCKS5(域名交给代理解析),用户名和密码可选,密码按「代理地址 + 用户名」进钥匙串(`SecretRef::Proxy`),代理要求认证或拒绝时直接报错、不弹问答。跳板和代理不叠加。删除被别的主机用作跳板的主机:确认框写明台数,那些主机的跳板列表里留下「已删除的主机」占位(库里 `ON DELETE SET NULL`),连接时直接报错,不悄悄改成直连或少跳一级,表单里留着占位就保存会报错;删除不重连已打开的连接。在线升级:主域名 sLine truncated
已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一主机可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或主机;分组支持任意层级嵌套,主机也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的主机一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的三个图标(主机 / 端口转发 / 凭据)切换;一条规则经由一个已保存的主机,启动时自己建一条 SSH 连接,不计入主机的连接状态,主机「断开连接」不停它;删除主机(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。凭据是侧栏的第三个列表(密码 / 密钥 / SSH Agent 三种),**凭据带用户名**:主机「使用凭据」时不再填用户名,改凭据的用户名所有用它的主机一起变;主机对话框的「认证方式」是三选一:「密码」(只有用户名和密码,密码留空就每次连接时询问,不悄悄试密钥)、「使用凭据」和「无密码」(只有用户名:依次试服务器免认证、SSH Agent 和 `~/.ssh` 的默认私钥,服务器要求密码时直接报错、不询问;密钥之后再要验证码照样问);主机自己不再有私钥文件,要用私钥就建密钥凭据;密钥凭据的私钥有三种来源——「本机文件」(只引用路径,不导入内容)、「粘贴」和「生成新密钥」(Ed25519 默认,另有 RSA 4096)——在同一个对话框里(类型选「密钥」后多一排来源单选),不按类型拆对话框,因为编辑时要能改类型、换钥匙,用它的主机一起跟着变;粘贴和生成的私钥由 ShellRS 写成数据目录 `keys/` 下的文件(不进钥匙串:Windows 凭据管理器一条最多 2560 字节,RSA 私钥放不下),凭据引用它的路径,编辑时就显示为「本机文件」;空列表和空白处的右键菜单多一个「生成密钥…」,打开的是同一个对话框、已选好「密钥 · 生成新密钥」,密钥凭据的右键菜单有「复制公钥」;删除仍在用的凭据要确认并写明台数,删除后那些主机保留用户名,密码凭据的改成「密码」(连接时询问),密钥和 Agent 凭据的改成「无密码」(`CredentialKind::without_credential`),密码和私钥不复制过去,已打开的连接不断开;主机对话框里不做「新建凭据…」(设计规范不叠弹层);Windows 上 Agent 走 OpenSSH Agent 的命名管道,「无密码」也试它;暂不做自定义 Agent 套接字、Pageant、凭据的外部 CLI 接口和凭据排序。主机对话框「分组」下面是「连接方式」三选一:「直接连接」(默认)、「SSH 跳板」和「代理连接」。跳板选的是已保存的主机,可多级,**只走这里列出的链**:跳板主机自己的连接方式不参与(所以不会成环),每台用它自己的地址、端口、认证和 known_hosts 条目;跳板列表不做拖动排序(删了重加)。代理是 HTTP(`CONNECT`)或 SOCKS5(域名交给代理解析),用户名和密码可选,密码按「代理地址 + 用户名」进钥匙串(`SecretRef::Proxy`),代理要求认证或拒绝时直接报错、不弹问答。跳板和代理不叠加。删除被别的主机用作跳板的主机:确认框写明台数,那些主机的跳板列表里留下「已删除的主机」占位(库里 `ON DELETE SET NULL`),连接时直接报错,不悄悄改成直连或少跳一级,表单里留着占位就保存会报错;删除不重连已打开的连接。在线升级:主域名 sLine truncated
做 UI 之前先读 `gpui-kit` 与 `gpui-kit-design-guides` 两个技能(其中的 Coding Guides 和 Design Guides 在本仓库是规范,不是参考)。不要凭记忆臆造 gpui-kit API:以 `~/.cargo/registry/src/*/gpui-component-0.6.1/`、`gpui-base-0.6.1/` 的源码或 `https://gpui-kit.com/component/<name>.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 下不能直接复制文件),备份失败只打日志、照样迁移。
@@ -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"
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"
version = "0.1.0-beta.3"
edition = "2024"
publish = false
description = "SSH session manager with local terminal support"
+3 -1
View File
@@ -6,6 +6,8 @@
ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已有的窗口调到前面,新启动的那个随即退出。两个 ShellRS 各自把主机读进内存再写回同一个数据库,会互相覆盖对方的改动。(用 `SHELLRS_DATA_DIR` 指到不同数据目录的算不同的 ShellRS,可以同时运行。)要并排看两个终端或 SFTP,把标签拖成分栏即可。
在 macOS 上,点窗口左上角的关闭按钮只是把 ShellRS 隐藏到后台:已打开的连接、SFTP 传输、端口转发和外部 CLI 照常运行,点 Dock 图标恢复窗口,原来的标签都在。要真正退出用 ⌘Q,或者右键 Dock 图标选「退出」。Windows 和 Linux 上关闭窗口就是退出。
## 主机列表
左侧栏里保存的每一项是一台主机:地址、端口和认证方式。同一台机器用不同用户登录,就保存成两台主机。
@@ -292,7 +294,7 @@ ShellRS 启动 30 秒后检查一次有没有新版本,之后每 6 小时检
1. 核对 tag 与 `Cargo.toml` 一致,截出更新说明,确认代码里有发布公钥;
2. 构建并打包:macOS 合成 universal,签名、公证、staple,产出升级用的 `.app.zip` 和首次安装用的 `.dmg`;Windows 用 Inno Setup 打安装程序;Linux 打 AppImage;
3. 在 `release` 环境里人工批准后:给每个包签名,上传到 R2(`dl.shellrs.com/releases/<版本>/`),建 GitHub Release,**最后**才替换更新清单 `dl.shellrs.com/update/v1/<通道>.json` 并清 CDN 缓存。
3. 在 `release` 环境里人工批准后:给每个包签名,上传到 R2(`dl.shellrs.com/releases/<版本>/`),建 GitHub Release,**最后**才替换更新清单 `dl.shellrs.com/update/v1/<通道>.json` 并清 CDN 缓存。清单里每个包有两个下载地址:先 R2,下载失败时客户端接着试 GitHub Release(仓库是公开的,下载不用登录)。
版本号带 `-` 的(如 `0.3.0-beta.1`)只发到 beta 通道;正式版同时成为 beta 通道的最新版。在 Actions 里手动运行这个 workflow 只构建打包、不发布,用来演练。客户端只接受比自己新的版本,所以发错的版本撤不回来:把清单改回上一版能阻止更多人升级,修复要发新的补丁版。
+16 -4
View File
@@ -9,22 +9,34 @@
"notes_url": "https://shellrs.com/changelog#0.2.0",
"assets": {
"macos-aarch64": {
"urls": ["https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-universal.app.zip"],
"urls": [
"https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-universal.app.zip",
"https://github.com/since2006/shell-rs/releases/download/v0.2.0/ShellRS-0.2.0-macos-universal.app.zip"
],
"size": 41234567,
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
},
"macos-x86_64": {
"urls": ["https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-universal.app.zip"],
"urls": [
"https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-universal.app.zip",
"https://github.com/since2006/shell-rs/releases/download/v0.2.0/ShellRS-0.2.0-macos-universal.app.zip"
],
"size": 41234567,
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
},
"windows-x86_64": {
"urls": ["https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-windows-x86_64-setup.exe"],
"urls": [
"https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-windows-x86_64-setup.exe",
"https://github.com/since2006/shell-rs/releases/download/v0.2.0/ShellRS-0.2.0-windows-x86_64-setup.exe"
],
"size": 18234567,
"sha256": "1111111111111111111111111111111111111111111111111111111111111111"
},
"linux-x86_64": {
"urls": ["https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-linux-x86_64.AppImage"],
"urls": [
"https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-linux-x86_64.AppImage",
"https://github.com/since2006/shell-rs/releases/download/v0.2.0/ShellRS-0.2.0-linux-x86_64.AppImage"
],
"size": 28234567,
"sha256": "2222222222222222222222222222222222222222222222222222222222222222"
}
+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();
+8 -4
View File
@@ -31,10 +31,14 @@ pub const DOWNLOAD_PAGE: &str = "https://shellrs.com/download";
/// and a spare kept offline so the first can be replaced. Rotating means
/// shipping a release that knows the new key before signing with it.
///
/// Empty until the release keys exist (see the release section of
/// `README.md`); the release workflow refuses to build a release without
/// them, and a build without them rejects every manifest.
pub const TRUSTED_KEYS: &[&str] = &[];
/// The release workflow refuses to build a release while this is empty: a
/// build without keys rejects every manifest.
pub const TRUSTED_KEYS: &[&str] = &[
// In use: the release workflow signs with it (key id CD239E668A535CB0).
"RWSwXFOKZp4jzaKmM9EkpY3iIyI0vMzi4EhFDlXwGy+XYiaHIWZY4ELa",
// Spare, kept offline (key id F3B9F08DE80AF399).
"RWSZ8wrojfC584RC+h7Frcp/pDW0pd7TaB4pO6bTVVOgSj6mo1KFs0sh",
];
/// Which stream of releases a copy follows. Beta also gets every stable
/// release that is not behind it.
+10 -1
View File
@@ -335,6 +335,11 @@ pub(crate) mod tests {
.arg(&out)
.env("MINISIGN_KEY_FILE", &secret)
.env_remove("MINISIGN_PASSWORD")
// As the release workflow sets it.
.env(
"SHELLRS_MIRROR_BASE",
"https://github.com/since2006/shell-rs/releases/download/v0.2.0",
)
.output()
.unwrap();
assert!(made.status.success(), "{made:?}");
@@ -349,9 +354,13 @@ pub(crate) mod tests {
assert_eq!(manifest.notes, "### 新增\n\n- 在线升级\n");
let mac = &manifest.assets["macos-aarch64"];
assert_eq!(mac, &manifest.assets["macos-x86_64"]);
// dl.shellrs.com first, the GitHub release only when it fails.
assert_eq!(
mac.urls,
["https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-universal.app.zip"]
[
"https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-universal.app.zip",
"https://github.com/since2006/shell-rs/releases/download/v0.2.0/ShellRS-0.2.0-macos-universal.app.zip",
]
);
let package = dist.join("ShellRS-0.2.0-linux-x86_64.AppImage");
assert_eq!(
-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() {