设置「关于」简化为「应用更新」三行,更新渠道可在运行时切换

- 「关于」页只剩「应用更新」一组:当前版本(下面是状态,右边是版本号和一个按钮:
  检查更新,或重启并安装… / 下载更新 / 前往下载页)、更新渠道、自动升级;
  构建号和平台挪到版本号的悬停提示里,去掉官网、更新日志链接和更新内容
- 更新渠道(稳定版 / Beta)存在 settings.json,默认是安装包自己的渠道;切换后丢掉
  已找到和已下好的版本,开着自动升级时立刻查新渠道
- 状态文案改成整句,去掉「上次检查」时间;设置页和更新对话框共用 UpdateStep,
  对话框的按钮改叫「重启并安装」
- 主机对话框「无密码」和「SSH 跳板」的说明文字缩短
- 新增切换渠道的 UI 测试,假更新源按渠道签发清单
This commit is contained in:
Xuz committed 2026-10-01 14:43:17 +08:00
1 parent 2dff7be7db
commit 03dfd8d8eb
14 files changed
+438 -281

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
# 更新日志
每个版本一节,标题写成 `## [版本号]`,可以在后面加日期。发布流程(`.github/workflows/release.yml`)把与 tag 同版本的那一节原样作为更新说明:显示在 GitHub Release、ShellRS 的更新对话框和 设置 › 关于 里,用 Markdown 书写,面向使用者。
每个版本一节,标题写成 `## [版本号]`,可以在后面加日期。发布流程(`.github/workflows/release.yml`)把与 tag 同版本的那一节原样作为更新说明:显示在 GitHub Release 和 ShellRS 的更新对话框里,用 Markdown 书写,面向使用者。
## [0.1.0]
+5 -5
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` 为准。
@@ -67,13 +67,13 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- `server.rs`:`CliServer` 与可注入的 `CliBackend` trait;`backend.rs` 是真实实现 `SshCliBackend`(exec 走 `ssh::run_command`,传输走 `sftp_provider`)。
- `install.rs`:把 `shellrs` 放进 PATH(macOS / Linux 是符号链接)、Agent skill 的安装 / 更新 / 移除,纯文件系统操作,路径全来自 `IntegrationPaths`;`SKILL.md` 是 skill 正文(`include_str!`)。`install_windows.rs`:Windows 的做法(复制 + 用户 PATH),`cfg(any(windows, test))`,所以在 Mac 上也跑它的测试。`integration.rs`:设置页用的状态实体 `CliIntegration`。
- `update/` — 在线升级,不依赖 `workspace` 和 `settings`(只用 `app` 的路径和动作)。
- `build_info.rs`:编译期由发布流程注入的 `SHELLRS_UPDATE_CHANNEL`(stable / beta;没有就是开发构建,从不检查)、`SHELLRS_COMMIT`、`SHELLRS_BUILD_DATE`、`SHELLRS_APPLE_TEAM_ID`,清单地址 `MANIFEST_URL`,以及发布公钥 `TRUSTED_KEYS`(两个槽位:日常用的和离线备用的;为空时 release workflow 拒绝发布)。
- `build_info.rs`:编译期由发布流程注入的 `SHELLRS_UPDATE_CHANNEL`(stable / beta;没有就是开发构建,从不检查;有的话只决定「更新渠道」的默认值,实际查哪个渠道由设置决定,`UpdateServices::release_build`)、`SHELLRS_COMMIT`、`SHELLRS_BUILD_DATE`、`SHELLRS_APPLE_TEAM_ID`,清单地址 `MANIFEST_URL`,以及发布公钥 `TRUSTED_KEYS`(两个槽位:日常用的和离线备用的;为空时 release workflow 拒绝发布)。
- `manifest.rs`(纯函数):清单是一个**信封** `{manifest: <清单原文>, signature: <minisign 签名>}`,一个文件、一次请求,不会出现清单和签名缓存不同步;`Manifest::offer(当前版本, 平台 key, 本机抽签)` 给出 `Offer::{UpToDate, Update(Release), Manual}`。格式只加字段:不认识的字段忽略,`schema` 比 `SCHEMA` 大的清单只读出版本、让用户去下载页。平台 key 是 `macos-aarch64` / `macos-x86_64` / `windows-x86_64` / `linux-x86_64`(`platform.rs`),macOS 两个 key 指向同一个 universal 包。
- `verify.rs`:`open_envelope` 先验签再解析,签名的 trusted comment 必须是 `shellrs-manifest <通道> <版本>` 且和清单一致(防拿旧清单冒充新版本、拿 beta 清单冒充 stable);安装包靠清单里的大小和 SHA-256(`verify_file`)。debug 构建额外信任 `SHELLRS_UPDATE_PUBLIC_KEY`,release 构建只信编进去的。
- `feed.rs`:`UpdateFeed` trait(阻塞,只搬字节,所以假 feed 绕不过验签)与真实的 `HttpFeed`(reqwest blocking,默认特性里的 rustls + 系统代理:从 Finder / 开始菜单启动的进程没有 `HTTPS_PROXY`);下载写 `.part`、用 Range 续传、`urls` 依次回退,`timeout` 在阻塞客户端里是每次读的超时,所以等于停滞判断;超时被包成「解码响应体出错」,`read_error` 从内层的 `reqwest::Error` 认出来。debug 构建可用 `SHELLRS_UPDATE_URL` 指向本地服务器。客户端在工作线程上按任务新建(阻塞客户端自带 runtime,不能在 async 上下文里建或丢)。
- `install.rs`:纯函数 `detect` 决定 `InstallKind`(`MacBundle` / `WindowsSetup` / `AppImage` / `Unsupported(..)`:开发构建、App Translocation、DMG 里运行、没写权限、不是安装程序装的、不是 AppImage);`Installer` trait 的 `stage` 在工作线程上做慢的部分,`apply` 在 UI 线程上只改名或准备一个进程,返回 `Relaunch::{Restart(path), Hand(Launch), Nothing}`。平台实现 `install_macos.rs`(`ditto` 解到 app 旁边同一卷上的 `.ShellRS.app.update-<版本>`,`codesign` 核对 Team ID,两次改名交换,第二步失败就换回去)、`install_windows.rs`(隐藏的 PowerShell 等 ShellRS 退出后静默运行新版本的安装程序,再按需启动 ShellRS)、`install_linux.rs`(复制到 `$APPIMAGE` 旁边,一次改名覆盖;重启用 `$APPIMAGE`,current_exe 是会消失的挂载点)都在 `cfg(test)` 下也编译,Mac 上能跑它们的测试;外部命令在 `MacTools` 之类的接缝后面。
- `updater.rs`:`Updater` 实体,阶段 `Phase::{Off, Idle, Checking, UpToDate, Available, Downloading, Verifying, Ready, Installing, Failed{stage, error}}`。每个任务一个 `shellrs-update` 线程,只在有任务时每 100 ms 轮询事件(同端口转发),新任务取消旧任务、丢掉旧任务的事件。`start()`(只在生产路径调用):报告上一次更新(读 `applied.json`)、30 秒后清理并第一次检查、之后每 6 小时(带抖动)、`on_app_quit` 时把 Ready 的版本装好。下载进度只 `notify`(设置页和对话框观察 `Updater`),阶段变化才发 `UpdaterEvent::Changed`,工作区只订阅它来重画标题栏。
- `status.rs`(纯函数):设置页的状态行、标题栏按钮 `UpdateBadge`、哪些按钮可用,以及 `restart_note(RestartImpact)`。`update_dialog.rs`:更新对话框。
- `status.rs`(纯函数):设置页的状态行、标题栏按钮 `UpdateBadge`、哪些按钮可用、设置页和对话框共用的下一步 `UpdateStep`(`UpdateSnapshot::step`),以及 `restart_note(RestartImpact)`。`update_dialog.rs`:更新对话框。
- `shared/` — 多个功能共用的展示片段(`ClosableTabTitle`、`HostMark`、`close_tab_items`,表单里「几选一」的分段控件 `SegmentedControl` / `Segment`,表单对话框的「取消 + 提交」页脚 `commit_footer`、错误行 `form_error`、端口字段的 `parse_port` 和删除确认 `confirm_delete`,以及远程终端和 SFTP 标签共用的「重命名标签」对话框 `open_rename_tab_dialog`,面板实现 `RenamableTab` 即可接入)。
关键流程与不变量:
@@ -117,7 +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 分栏;将来要做,共享的数据和服务得先提到应用层。
- **在线升级的接入照外部 CLI 的先例。** `new_with_services` 只建一个没有服务的 `Updater`(阶段 `Off`、从不联网),生产路径 `Workspace::new` 再 `set_services(UpdateServices::system(cx.app_path()))` 和 `start()`;UI 测试经 `workspace.updater()` 注入假的 feed、安装器、测试公钥和临时目录。「自动检查并下载更新」存在 `AppSettings::update`,工作区在 `observe_in(&settings)` 里 `sync_updater`,`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` 的「发布」。
- **库只往前升,旧版本不碰新库。** `migrate` 遇到 `user_version > SCHEMA_VERSION`(或早于第一步)直接报错、不动文件(`open_store` 退回内存 store 并提示);迁移已有的库之前先 `VACUUM INTO '<库>.v<旧版本>.bak'`(WAL 下不能直接复制文件),备份失败只打日志、照样迁移。
- **窗口还在搭的时候不能直接弹通知。** 通知层属于窗口的 `Root`,而 `Root` 要等 `open_window` 的构造闭包返回才挂上;在那之前 `window.push_notification` 会 panic(「window first layer should be a gpui_component::Root」),应用启动即崩溃。`main` 里读库 / 读设置的问题、`Workspace::new` 里外部 CLI 启动失败,都用 `workspace::notify_once_open`(`window.defer`)延后到窗口建好。`cx.emit` 出去的事件本来就在构造结束后才送达,订阅里弹通知没有这个问题(自动开启的转发启动失败就是这样报的)。
@@ -175,4 +175,4 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
## 测试约定
UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity<HostStore>`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `HostStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("host-row", id)`、`("host-os", id)` 与开始页的 `("recent-host-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", explorer_id)`、`("local-pane"|"remote-pane", explorer_id)`(SFTP 的元素都按标签 id,测试里第一个 SFTP 标签是 `SFTP_TAB` = 1)、`("close-terminal", id)`、设置的 `open-settings`(主机面板底部)/ `settings`(页面)/ `settings-tab` / `close-settings`、设置页只渲染当前分类(左栏条目的 id 由 `Settings` 按位置生成,是 `within("settings")` 里的 `0-<序号>`,外观是 `0-0`、终端是 `0-1`、外部 CLI 是 `0-2`、关于是 `0-3`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-<agent>` 与 `skill-status-<agent>`(agent 为 `agents` / `codex` / `claude` / `opencode`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)`(`aria_label` 是「32 ms」或「超时」)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、主机行的 tooltip `host-tooltip`(`aria_label` 是 `user@address:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)与其下的备注 `host-tooltip-notes`(`aria_label` 是备注全文,没有备注时不存在)、`recent-hosts`、`("recent-host", id)`、`recent-new-host`、`host-search`、`new-host`、`new-group`、`theme-toggle`、窗口状态栏右侧的终端尺寸 `status-terminal-size`(`aria_label` 形如「120×36」,即列×行;本地或远程终端在前时才有,SFTP 标签、开始页和设置页没有这一格)、标题栏的侧栏切换 `show-hosts` / `show-forwards`(`checked()` 是当前显示的那个,侧栏隐藏时两个都不是;有转发在跑时后者的 `aria_label` 是「端口转发,N 条运行中」)、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`host-name` / `host-address` / `host-port` / `host-user` / `host-password`、「无密码」下面的说明 `host-no-password-note`、`group-name`、`remote-path`。SFTP 相关的还有:各侧面板里的 `path-select`、`bookmarks`、`up`、`root`、`home`、`refresh`、`back`、`forward`、`upload`(本地)/ `download`(远程)、`delete`、`rename`、`properties`、`new`(同名按钮在两侧都有,要用 `within((pane, id))` 查)、行 `file:<名称>`(拖放目标;行中心是空白处,**点选、双击、⌘ / Shift 单击和拖文件都要对着** `name:<名称>`)与名称 `name:<名称>`(`aria_selected` 表示已选中)、名称单元格 `name-cell:<名称>`(单击它任何地方都选中这一行,从它右边的空白拖是框选)、大小单元格 `size:<名称>`(框选测试从它拖到另一行的它)(`aria_label` 是显示的大小,目录为空)、列标题 `column:<列键>`、框选时的 `selection-rectangle`、列表为空时的占位文字 `list-placeholder`(`aria_label` 是显示的文字;假传输的 `slow_home` 让远程主目录永远不回来,用来测慢网)、文件拖动预览 `file-drag-preview`、`("file-list", 0|1)`、`entry-name`(重命名 / 新建表单)、`("perm", 0..9)` 与 `perm-octal` / `perm-recursive` / `perm-dir-x`(属性对话框)、`download-confirm` / `download-target` / `download-browse`、上传Line truncated
UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity<HostStore>`:生产环境由 `main.rs` 从数据库加载,测试用 `open_workspace_with_store` 注入 `HostStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("host-row", id)`、`("host-os", id)` 与开始页的 `("recent-host-os", id)`(`aria_label` 是系统名,未探测时是「未探测到系统」)、`("group-row", id)`、`("terminal", id)`、`("explorer", explorer_id)`、`("local-pane"|"remote-pane", explorer_id)`(SFTP 的元素都按标签 id,测试里第一个 SFTP 标签是 `SFTP_TAB` = 1)、`("close-terminal", id)`、设置的 `open-settings`(主机面板底部)/ `settings`(页面)/ `settings-tab` / `close-settings`、设置页只渲染当前分类(左栏条目的 id 由 `Settings` 按位置生成,是 `within("settings")` 里的 `0-<序号>`,外观是 `0-0`、终端是 `0-1`、外部 CLI 是 `0-2`、关于是 `0-3`;开关是 `group-<组>` › `item-<项>` › `check`;外部 CLI 页的 `cli-binary`(安装 / 移除按钮)与 `cli-binary-status`、`skill-<agent>` 与 `skill-status-<agent>`(agent 为 `agents` / `codex` / `claude` / `opencode`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)`(`aria_label` 是「32 ms」或「超时」)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、主机行的 tooltip `host-tooltip`(`aria_label` 是 `user@address:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)与其下的备注 `host-tooltip-notes`(`aria_label` 是备注全文,没有备注时不存在)、`recent-hosts`、`("recent-host", id)`、`recent-new-host`、`host-search`、`new-host`、`new-group`、`theme-toggle`、窗口状态栏右侧的终端尺寸 `status-terminal-size`(`aria_label` 形如「120×36」,即列×行;本地或远程终端在前时才有,SFTP 标签、开始页和设置页没有这一格)、标题栏的侧栏切换 `show-hosts` / `show-forwards`(`checked()` 是当前显示的那个,侧栏隐藏时两个都不是;有转发在跑时后者的 `aria_label` 是「端口转发,N 条运行中」)、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`host-name` / `host-address` / `host-port` / `host-user` / `host-password`、「无密码」下面的说明 `host-no-password-note`、`group-name`、`remote-path`。SFTP 相关的还有:各侧面板里的 `path-select`、`bookmarks`、`up`、`root`、`home`、`refresh`、`back`、`forward`、`upload`(本地)/ `download`(远程)、`delete`、`rename`、`properties`、`new`(同名按钮在两侧都有,要用 `within((pane, id))` 查)、行 `file:<名称>`(拖放目标;行中心是空白处,**点选、双击、⌘ / Shift 单击和拖文件都要对着** `name:<名称>`)与名称 `name:<名称>`(`aria_selected` 表示已选中)、名称单元格 `name-cell:<名称>`(单击它任何地方都选中这一行,从它右边的空白拖是框选)、大小单元格 `size:<名称>`(框选测试从它拖到另一行的它)(`aria_label` 是显示的大小,目录为空)、列标题 `column:<列键>`、框选时的 `selection-rectangle`、列表为空时的占位文字 `list-placeholder`(`aria_label` 是显示的文字;假传输的 `slow_home` 让远程主目录永远不回来,用来测慢网)、文件拖动预览 `file-drag-preview`、`("file-list", 0|1)`、`entry-name`(重命名 / 新建表单)、`("perm", 0..9)` 与 `perm-octal` / `perm-recursive` / `perm-dir-x`(属性对话框)、`download-confirm` / `download-target` / `download-browse`、上传Line truncated
+7 -3
View File
@@ -89,7 +89,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
- **外观 → 常规 → 应用外观**:深色、浅色、跟随系统,默认跟随系统;选「跟随系统」时随系统的深浅色切换。标题栏的主题按钮也会改写这一项。
- **终端 → 字体配置**:字体(只列出本机安装的等宽字体,默认是系统的等宽字体,macOS 上为 Menlo)、字号(8–32 像素,默认 13)、行高(字号的 1.0–2.0 倍,默认 1.54,即原来的 20 像素行高)。改动立即作用于所有已打开的终端。下方的预览按终端的排法显示字母、数字、易混字符、符号、中文、制表符和一行命令。设置文件里的字体在本机没有安装时,使用默认字体。
- **外部 CLI**:见下一节。
- **关于**:版本、构建和平台,以及在线升级,见「下载与更新」。
- **关于**:「应用更新」一组:当前版本和检查更新、更新渠道、自动升级,见「下载与更新」。
## 外部 CLI
@@ -273,9 +273,13 @@ shellrs download <ID> <远程路径> <本地路径>
| Windows x64 | 安装程序,按当前用户安装到 `%LOCALAPPDATA%\Programs\ShellRS`,不需要管理员 | 支持 |
| Linux x64 | AppImage | 支持 |
ShellRS 启动 30 秒后检查一次有没有新版本,之后每 6 小时检查一次。有新版本就在后台下载并校验,完成后标题栏右上角出现更新图标。点开可以看更新内容,点「重启更新」就换成新版本并重新打开;不点也没关系,退出 ShellRS 时会装好,下次打开就是新版本。重启会断开已打开的连接、停止传输(续传进度保留)和端口转发,对话框里会写明具体有哪些。
ShellRS 启动 30 秒后检查一次有没有新版本,之后每 6 小时检查一次。有新版本就在后台下载并校验,完成后标题栏右上角出现更新图标。点开可以看更新内容,点「重启并安装」就换成新版本并重新打开;不点也没关系,退出 ShellRS 时会装好,下次打开就是新版本。重启会断开已打开的连接、停止传输(续传进度保留)和端口转发,对话框里会写明具体有哪些。
「设置 › 关于」可以随时检查更新,看下载进度和更新内容,也可以关掉「自动检查并下载更新」。关掉之后只在手动检查时才联网,发现新版本时由你决定是否下载。
「设置 › 关于」里的「应用更新」只有三行:
- **当前版本**:下面写着现在的状态(已是最新、正在下载、已下载等),右边是版本号和一个按钮:平时是「检查更新」,下载好之后变成「重启并安装…」。构建号和平台在版本号的悬停提示里。
- **更新渠道**:稳定版适合日常使用;Beta 可以提前收到测试版本,稳定版发布时也会收到。切换后马上按新渠道检查(关掉自动升级时等你手动检查)。
- **自动升级**:关掉之后只在手动检查时才联网,发现新版本时由你决定是否下载。
- **校验**:更新清单由 ShellRS 的发布密钥签名,安装包按清单里的大小和 SHA-256 核对,任何一步对不上都不会安装。macOS 上还会核对新版本的开发者签名。
- **隐私**:检查更新只发送 ShellRS 的版本号、操作系统和 CPU 架构,不发送任何能识别这台电脑或用户的信息。
+2 -4
View File
@@ -76,8 +76,7 @@ impl AuthSource {
}
/// What 「无密码」 tries, under the choice.
const NO_PASSWORD_NOTE: &str =
"依次尝试服务器免认证、SSH Agent 和 ~/.ssh 中的默认私钥;服务器要求密码时连接失败,不会询问。";
const NO_PASSWORD_NOTE: &str = "依次尝试服务器免认证、SSH Agent 和 ~/.ssh 中的默认私钥。";
/// How a host is reached, as the form's 「连接方式」 offers it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -109,8 +108,7 @@ impl RouteChoice {
}
/// What the jump-host box says above the chain.
const JUMP_NOTE: &str =
"依次经过跳板主机连接到当前主机,可添加多台。跳板主机自己的「连接方式」在这里不生效。";
const JUMP_NOTE: &str = "依次经过跳板主机连接到当前主机,可添加多台。";
/// What the proxy box says under its fields.
const PROXY_NOTE: &str = "目标地址由代理服务器解析;代理不需要认证时,用户名和密码留空。";
+40 -4
View File
@@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
use crate::explorer::FileSizeFormat;
use crate::terminal::{DEFAULT_FONT_SIZE, DEFAULT_LINE_HEIGHT, FONT_SIZE_RANGE, LINE_HEIGHT_RANGE};
use crate::update::Channel;
/// Everything the settings page changes. Each field falls back to its
/// default when the file does not mention it, so a file written by an older
@@ -30,18 +31,38 @@ pub struct ExternalCliSettings {
pub enabled: bool,
}
/// 关于 → 更新.
/// 关于 → 应用更新.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct UpdateSettings {
/// Look for a newer ShellRS in the background and download it. On
/// unless the user turns it off.
/// 自动升级: look for a newer ShellRS in the background and download
/// it. On unless the user turns it off.
pub automatic: bool,
/// 更新渠道. A release build starts on the channel it was built for.
pub channel: Channel,
}
impl Default for UpdateSettings {
fn default() -> Self {
Self { automatic: true }
Self {
automatic: true,
channel: Channel::of_this_build().unwrap_or(Channel::Stable),
}
}
}
impl Choice for Channel {
const ALL: &'static [Self] = &[Self::Stable, Self::Beta];
fn key(self) -> &'static str {
Channel::key(self)
}
fn label(self) -> &'static str {
match self {
Self::Stable => "稳定版",
Self::Beta => "Beta",
}
}
}
@@ -257,6 +278,21 @@ mod tests {
assert!(!settings.update.automatic);
}
#[test]
fn the_update_channel_is_the_builds_until_one_is_chosen() {
// A development build has no channel of its own.
let settings: AppSettings = serde_json::from_str(r#"{"update":{}}"#).unwrap();
assert_eq!(settings.update.channel, Channel::Stable);
let settings: AppSettings =
serde_json::from_str(r#"{"update":{"channel":"beta"}}"#).unwrap();
assert_eq!(settings.update.channel, Channel::Beta);
assert_eq!(
serde_json::to_value(&settings.update).unwrap()["channel"],
"beta"
);
assert_eq!(Channel::from_key("beta"), Some(Channel::Beta));
}
#[test]
fn terminal_font_numbers_are_kept_in_range_and_rounded() {
let font = |size, line_height| TerminalFontSettings {
+106 -146
View File
@@ -4,11 +4,11 @@ use gpui_kit::component::{
dock::{BasePanel, Panel, PanelEvent, TabGroup},
group_box::GroupBoxVariant,
h_flex,
link::Link,
label::Label,
menu::PopupMenu,
separator::Separator,
setting::{NumberFieldOptions, SettingField, SettingGroup, SettingItem, SettingPage, Settings},
text::TextView,
tooltip::Tooltip,
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
@@ -25,7 +25,7 @@ use crate::terminal::{
FONT_SIZE_RANGE, LINE_HEIGHT_RANGE, TerminalFontPreview, is_font_installed,
monospace_font_families,
};
use crate::update::{Phase, Tone, UpdateSnapshot, Updater, build_info, platform};
use crate::update::{Phase, Tone, UpdateSnapshot, UpdateStep, Updater, build_info, platform};
use super::{AppSettings, Choice, SettingsStore};
@@ -374,21 +374,25 @@ fn external_cli_groups(panel: &SettingsPanel, cx: &App) -> Vec<SettingGroup> {
]
}
/// 关于: which ShellRS this is, and updating it.
/// 关于: 应用更新, as three rows: the version with where updating stands,
/// the channel, and 自动升级.
fn about_groups(panel: &SettingsPanel, cx: &App) -> Vec<SettingGroup> {
let snapshot = panel.updater.read(cx).snapshot();
let (reader, writer) = (panel.store.clone(), panel.store.clone());
let notes = snapshot
.release
.as_ref()
.filter(|release| !release.notes.trim().is_empty())
.map(|release| (release.version.to_string(), release.notes.clone()));
let mut groups = vec![
SettingGroup::new().title("ShellRS").items([version_item()]),
SettingGroup::new().title("更新").items([
update_status_item(snapshot),
vec![
SettingGroup::new().title("应用更新").items([
current_version_item(snapshot),
SettingItem::new(
"自动检查并下载更新",
"更新渠道",
choice_field(
&panel.store,
|settings| settings.update.channel,
|settings, channel| settings.update.channel = channel,
),
)
.description("稳定版适合日常使用,Beta 可提前接收测试版本。"),
SettingItem::new(
"自动升级",
SettingField::switch(
move |cx| reader.read(cx).settings().update.automatic,
move |automatic, cx| {
@@ -399,132 +403,83 @@ fn about_groups(panel: &SettingsPanel, cx: &App) -> Vec<SettingGroup> {
)
.default_value(true),
)
.description(
"在后台检查新版本并下载,下载完成后在标题栏右上角提示,退出或重启时安装。\
检查时只发送 ShellRS 的版本号、系统和架构。",
),
.description("检测到新版本后自动下载,下载完成后点击「重启并安装」完成更新。"),
]),
];
if let Some((version, notes)) = notes {
groups.push(
SettingGroup::new()
.title(format!("{version} 更新内容"))
.items([SettingItem::render(move |_, _, _| {
div().w_full().child(
TextView::markdown(
SharedString::from(format!("about-notes-{version}")),
notes.clone(),
)
.selectable(true),
)
})]),
);
}
groups
]
}
/// 版本, 构建 and 平台, with where to read more.
fn version_item() -> SettingItem {
SettingItem::render(|_, _, cx| {
let channel = match crate::update::Channel::of_this_build() {
Some(channel) => channel.key().to_string(),
None => "开发构建".into(),
};
let version = format!("{}({channel})", build_info::VERSION);
let build = match (build_info::COMMIT, build_info::BUILD_DATE) {
(Some(commit), Some(date)) => {
format!("{} · {date}", commit.get(..7).unwrap_or(commit))
}
(Some(commit), None) => commit.get(..7).unwrap_or(commit).to_string(),
_ => "本机构建".into(),
};
let row = |label: &'static str, value: String, id: &'static str| {
h_flex()
.gap_3()
.text_sm()
.child(
div()
.w(rems(4.))
.flex_shrink_0()
.text_color(cx.theme().muted_foreground)
.child(label),
)
.child(
div()
.id(id)
.test_support()
.aria_label(value.clone())
.child(value),
)
};
v_flex()
.w_full()
.gap_2()
.child(row("版本", version, "about-version"))
.child(row("构建", build, "about-build"))
.child(row(
"平台",
platform::platform_label().to_string(),
"about-platform",
))
.child(
h_flex()
.gap_4()
.text_sm()
.child(
Link::new("about-website")
.href("https://shellrs.com")
.child("官网"),
)
.child(
Link::new("about-changelog")
.href("https://shellrs.com/changelog")
.child("更新日志"),
),
)
})
.keywords(["版本", "关于", "ShellRS"])
}
/// Where updating stands, with 检查更新 and the step that applies.
fn update_status_item(snapshot: UpdateSnapshot) -> SettingItem {
SettingItem::render(move |_, _, cx| {
/// 当前版本: where updating stands under the title, and the version with the
/// one button that applies: 检查更新, or the step the version on offer
/// needs. Laid out like the page's other rows.
fn current_version_item(snapshot: UpdateSnapshot) -> SettingItem {
SettingItem::render(move |options, _, cx| {
let (text, tone) = snapshot.status_line();
let color = match tone {
Tone::Plain => cx.theme().muted_foreground,
Tone::Success => cx.theme().success,
Tone::Warning => cx.theme().warning,
Tone::Danger => cx.theme().danger,
};
let step: Option<(&'static str, &'static str, Box<dyn Action>)> =
if snapshot.phase == Phase::Ready {
Some(("show-update", "重启更新…", Box::new(ShowUpdate)))
} else if snapshot.needs_download_page() {
Some((
let version = SharedString::from(format!("v{}", build_info::VERSION));
let build = SharedString::from(build_description());
let step = snapshot.step().map(|step| -> (_, _, Box<dyn Action>) {
match step {
UpdateStep::Restart => ("show-update", "重启并安装…", Box::new(ShowUpdate)),
UpdateStep::DownloadPage => (
"open-download-page",
"前往下载页",
Box::new(OpenDownloadPage),
))
} else if snapshot.can_download() {
let label = if matches!(snapshot.phase, Phase::Failed { .. }) {
"重试下载"
} else {
"下载"
};
Some(("download-update", label, Box::new(DownloadUpdate)))
} else {
None
};
h_flex()
.w_full()
.justify_between()
.items_center()
),
UpdateStep::Download { retry } => (
"download-update",
if retry {
"重试下载"
} else {
"下载更新"
},
Box::new(DownloadUpdate),
),
}
});
let button = match step {
Some((id, label, action)) => Some(
Button::new(id)
.primary()
.with_size(options.size())
.label(label)
.on_click(move |_, window, cx| {
window.dispatch_action(action.boxed_clone(), cx)
}),
),
None => (snapshot.phase != Phase::Off).then(|| {
Button::new("check-for-updates")
.outline()
.with_size(options.size())
.icon(Icon::new(CatalogIcon::RefreshCw))
.label("检查更新")
.loading(snapshot.phase == Phase::Checking)
.disabled(!snapshot.can_check())
.on_click(|_, window, cx| window.dispatch_action(Box::new(CheckForUpdates), cx))
}),
};
// A narrow page stacks each row, the controls under the text.
let stacked = options.layout() == Axis::Vertical;
let row = if stacked {
v_flex()
} else {
h_flex().justify_between().items_center()
};
row.w_full()
.gap_3()
.child(
v_flex()
.gap_1()
.min_w_0()
.child(div().text_sm().child("状态:"))
.map(|text| {
if stacked {
text.w_full()
} else {
text.flex_1().max_w_3_5()
}
})
.child(Label::new("当前版本").text_sm())
.child(
div()
.id("update-status")
@@ -537,30 +492,35 @@ fn update_status_item(snapshot: UpdateSnapshot) -> SettingItem {
)
.child(
h_flex()
.gap_2()
.gap_3()
.flex_shrink_0()
.when(snapshot.phase != Phase::Off, |buttons| {
buttons.child(
Button::new("check-for-updates")
.outline()
.small()
.icon(Icon::new(CatalogIcon::RefreshCw))
.label("检查更新")
.loading(snapshot.phase == Phase::Checking)
.disabled(!snapshot.can_check())
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(CheckForUpdates), cx)
}),
)
})
.children(step.map(|(id, label, action)| {
Button::new(id).primary().small().label(label).on_click(
move |_, window, cx| window.dispatch_action(action.boxed_clone(), cx),
)
})),
.child(
div()
.id("about-version")
.test_support()
.aria_label(version.clone())
.text_sm()
.font_family(cx.theme().mono_font_family.clone())
.text_color(cx.theme().muted_foreground)
.tooltip(move |window, cx| {
Tooltip::new(build.clone()).build(window, cx)
})
.child(version),
)
.children(button),
)
})
.keywords(["更新", "升级", "版本", "下载"])
.keywords(["当前版本", "检查更新", "更新", "升级", "版本", "下载"])
}
/// The version's tooltip: which build this is, and for which system.
fn build_description() -> String {
let build = match (build_info::COMMIT, build_info::BUILD_DATE) {
(Some(commit), Some(date)) => format!("{} · {date}", commit.get(..7).unwrap_or(commit)),
(Some(commit), None) => commit.get(..7).unwrap_or(commit).to_string(),
_ => "本机构建".into(),
};
format!("{build}({})", platform::platform_label())
}
/// Where the `shellrs` command stands, with the one thing to do about it.
+6 -4
View File
@@ -2,9 +2,10 @@
//!
//! Only the release workflow sets `SHELLRS_UPDATE_CHANNEL`, so a `cargo run`
//! build has no channel and never looks for updates: it is not something a
//! published package could replace.
//! published package could replace. A release build starts on its own
//! channel; 设置 › 关于 › 更新渠道 switches it.
use serde::Deserialize;
use serde::{Deserialize, Serialize};
/// This build's version, from `Cargo.toml`.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -35,8 +36,9 @@ pub const DOWNLOAD_PAGE: &str = "https://shellrs.com/download";
/// them, and a build without them rejects every manifest.
pub const TRUSTED_KEYS: &[&str] = &[];
/// Which stream of releases a build follows.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
/// Which stream of releases a copy follows. Beta also gets every stable
/// release that is not behind it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Channel {
Stable,
+1 -1
View File
@@ -30,7 +30,7 @@ pub use error::UpdateError;
pub use feed::{HttpFeed, UpdateFeed};
pub use install::{InstallKind, Installer, Launch, Relaunch, Staged, Unsupported};
pub use manifest::{Manifest, Offer, Release};
pub use status::{RestartImpact, Tone, UpdateBadge, restart_note};
pub use status::{RestartImpact, Tone, UpdateBadge, UpdateStep, restart_note};
pub use update_dialog::{ImpactCounter, open_update_dialog};
pub use updater::{
ManualUpdate, Phase, Stage, UpdateServices, UpdateSnapshot, Updater, UpdaterEvent,
+83 -32
View File
@@ -17,11 +17,21 @@ pub struct UpdateBadge {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Tone {
Plain,
Success,
Warning,
Danger,
}
/// The one thing to do about the version on offer, when there is one.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UpdateStep {
/// Restart into the version downloaded.
Restart,
/// Download it by hand from the website.
DownloadPage,
/// Download it here; `retry` after a download that failed.
Download { retry: bool },
}
impl UpdateSnapshot {
/// The version on offer, found or needing the download page.
pub fn offered_version(&self) -> Option<String> {
@@ -40,58 +50,71 @@ impl UpdateSnapshot {
self.manual.is_some() || (self.release.is_some() && self.unsupported.is_some())
}
/// The line under 更新 on 设置 › 关于.
/// The line under 当前版本 on 设置 › 关于.
pub fn status_line(&self) -> (String, Tone) {
let offered = self.offered_version().unwrap_or_default();
match &self.phase {
Phase::Off => (
self.unsupported
.as_ref()
.map(|reason| reason.reason())
.unwrap_or_else(|| "不检查更新".into()),
format!(
"{}。",
self.unsupported
.as_ref()
.map(|reason| reason.reason())
.unwrap_or_else(|| "不检查更新".into())
),
Tone::Plain,
),
Phase::Idle => ("尚未检查更新".into(), Tone::Plain),
Phase::Idle => ("尚未检查更新。".into(), Tone::Plain),
Phase::Checking => ("正在检查更新…".into(), Tone::Plain),
Phase::UpToDate => (
match self.checked_at {
Some(at) => format!("已是最新版本 · 上次检查 {}", at.format("%H:%M")),
None => "已是最新版本".into(),
},
Tone::Success,
),
Phase::UpToDate => ("当前已是最新版本。".into(), Tone::Plain),
Phase::Available if self.manual.is_some() => (
format!("新版本 {offered} 需要从官网下载安装"),
format!("发现新版本 {offered},需要从官网下载安装。"),
Tone::Warning,
),
Phase::Available => match &self.unsupported {
Some(reason) => (
format!("发现新版本 {offered}。{}", reason.reason()),
format!("发现新版本 {offered}。{}。", reason.reason()),
Tone::Warning,
),
None => (format!("发现新版本 {offered}"), Tone::Plain),
None => (format!("发现新版本 {offered}。"), Tone::Plain),
},
Phase::Downloading { done, total } => (
format!("正在下载 {offered} · {}%", percent(*done, *total)),
format!("正在下载新版本 {offered}… {}%", percent(*done, *total)),
Tone::Plain,
),
Phase::Verifying => (format!("正在校验 {offered}…"), Tone::Plain),
Phase::Verifying => (format!("正在校验新版本 {offered}…"), Tone::Plain),
Phase::Ready => (
format!("{offered} 已就绪,退出或重启 ShellRS 时安装"),
Tone::Success,
format!("新版本 {offered} 已下载,重启 ShellRS 即可完成更新。"),
Tone::Plain,
),
Phase::Installing => (format!("正在安装 {offered}…"), Tone::Plain),
Phase::Installing => (format!("正在安装新版本 {offered}…"), Tone::Plain),
Phase::Failed { stage, error } => (
match stage {
Stage::Check => format!("检查失败:{error}"),
Stage::Download => format!("下载 {offered} 失败:{error}"),
Stage::Install => format!("安装 {offered} 失败:{error}"),
Stage::Check => format!("检查更新失败:{error}"),
Stage::Download => format!("下载新版本 {offered} 失败:{error}"),
Stage::Install => format!("安装新版本 {offered} 失败:{error}"),
},
Tone::Danger,
),
}
}
/// What the version on offer needs next, for the button next to the
/// version and the update dialog's.
pub fn step(&self) -> Option<UpdateStep> {
if self.phase == Phase::Ready {
Some(UpdateStep::Restart)
} else if self.needs_download_page() {
Some(UpdateStep::DownloadPage)
} else if self.can_download() {
Some(UpdateStep::Download {
retry: matches!(self.phase, Phase::Failed { .. }),
})
} else {
None
}
}
/// The title bar button's label, when the title bar shows one: a
/// version ready to install, one that has to be downloaded by hand, or
/// automatic downloads that keep failing.
@@ -219,7 +242,6 @@ mod tests {
installer: None,
}),
manual: None,
checked_at: None,
unsupported: None,
automatic: true,
download_failures: 0,
@@ -256,6 +278,10 @@ mod tests {
#[test]
fn each_phase_has_its_line() {
assert_eq!(snapshot(Phase::Checking).status_line().0, "正在检查更新…");
assert_eq!(
snapshot(Phase::UpToDate).status_line(),
("当前已是最新版本。".into(), Tone::Plain)
);
assert_eq!(
snapshot(Phase::Downloading {
done: 45,
@@ -263,11 +289,12 @@ mod tests {
})
.status_line()
.0,
"正在下载 0.2.0 · 45%"
"正在下载新版本 0.2.0… 45%"
);
assert_eq!(
snapshot(Phase::Ready).status_line().0,
"新版本 0.2.0 已下载,重启 ShellRS 即可完成更新。"
);
let (ready, tone) = snapshot(Phase::Ready).status_line();
assert_eq!(ready, "0.2.0 已就绪,退出或重启 ShellRS 时安装");
assert_eq!(tone, Tone::Success);
let failed = snapshot(Phase::Failed {
stage: Stage::Download,
error: UpdateError::Http(503),
@@ -275,13 +302,37 @@ mod tests {
assert_eq!(
failed.status_line(),
(
"下载 0.2.0 失败:更新服务器返回 HTTP 503".into(),
"下载新版本 0.2.0 失败:更新服务器返回 HTTP 503".into(),
Tone::Danger
)
);
let mut off = snapshot(Phase::Off);
off.unsupported = Some(Unsupported::DevelopmentBuild);
assert_eq!(off.status_line().0, "开发构建,不检查更新");
assert_eq!(off.status_line().0, "开发构建,不检查更新。");
}
#[test]
fn one_step_applies_at_a_time() {
assert_eq!(snapshot(Phase::Ready).step(), Some(UpdateStep::Restart));
assert_eq!(
snapshot(Phase::Available).step(),
Some(UpdateStep::Download { retry: false })
);
let failed = snapshot(Phase::Failed {
stage: Stage::Download,
error: UpdateError::Stalled,
});
assert_eq!(failed.step(), Some(UpdateStep::Download { retry: true }));
assert_eq!(
snapshot(Phase::Downloading { done: 1, total: 2 }).step(),
None
);
let mut unsupported = snapshot(Phase::Available);
unsupported.unsupported = Some(Unsupported::NotAppImage);
assert_eq!(unsupported.step(), Some(UpdateStep::DownloadPage));
let mut up_to_date = snapshot(Phase::UpToDate);
up_to_date.release = None;
assert_eq!(up_to_date.step(), None);
}
#[test]
+18 -19
View File
@@ -20,7 +20,7 @@ use gpui_kit::*;
use crate::app::{DownloadUpdate, OpenDownloadPage, RestartToUpdate};
use super::status::{RestartImpact, percent, restart_note};
use super::status::{RestartImpact, UpdateStep, percent, restart_note};
use super::updater::{Phase, Stage, UpdateSnapshot, Updater};
/// The dialog's width, in rems.
@@ -196,24 +196,23 @@ pub fn open_update_dialog(
/// 稍后, and the one step that applies now.
fn footer(snapshot: &UpdateSnapshot, dispatch: FocusHandle) -> DialogFooter {
let action: Option<(&'static str, &'static str, Box<dyn Action>)> =
if snapshot.phase == Phase::Ready {
Some(("restart-to-update", "重启更新", Box::new(RestartToUpdate)))
} else if snapshot.needs_download_page() {
Some((
"open-download-page",
"前往下载页",
Box::new(OpenDownloadPage),
))
} else if snapshot.can_download() {
let label = if matches!(snapshot.phase, Phase::Failed { .. }) {
"重试"
} else {
"下载"
};
Some(("download-update", label, Box::new(DownloadUpdate)))
} else {
None
};
snapshot.step().map(|step| -> (_, _, Box<dyn Action>) {
match step {
UpdateStep::Restart => {
("restart-to-update", "重启并安装", Box::new(RestartToUpdate))
}
UpdateStep::DownloadPage => (
"open-download-page",
"前往下载页",
Box::new(OpenDownloadPage),
),
UpdateStep::Download { retry } => (
"download-update",
if retry { "重试" } else { "下载" },
Box::new(DownloadUpdate),
),
}
});
let close = if action.is_some() { "稍后" } else { "关闭" };
DialogFooter::new()
.child(DialogClose::new().trigger(move |button| button.label(close)))
+36 -15
View File
@@ -13,7 +13,6 @@ use std::sync::{Arc, Mutex};
use std::time::Duration;
use async_channel::{Receiver, Sender, TryRecvError};
use chrono::{DateTime, Local};
use gpui_kit::*;
use rand::Rng as _;
use semver::Version;
@@ -46,8 +45,8 @@ pub struct UpdateServices {
pub feed: Arc<dyn UpdateFeed>,
pub installer: Arc<dyn Installer>,
pub keys: TrustedKeys,
/// `None` for a development build, which never looks.
pub channel: Option<Channel>,
/// `false` for a development build, which never looks.
pub release_build: bool,
pub current: Version,
/// Downloads, and the note the next start reads.
pub folder: PathBuf,
@@ -63,7 +62,7 @@ impl UpdateServices {
feed: Arc::new(HttpFeed::system()),
installer: system_installer(bundle),
keys: TrustedKeys::builtin(),
channel: Channel::of_this_build(),
release_build: Channel::of_this_build().is_some(),
current: build_info::version(),
draw: rollout_draw(&folder),
folder,
@@ -137,7 +136,6 @@ pub struct UpdateSnapshot {
pub current: Version,
pub release: Option<Release>,
pub manual: Option<ManualUpdate>,
pub checked_at: Option<DateTime<Local>>,
/// Why this copy cannot install updates itself, when it cannot.
pub unsupported: Option<Unsupported>,
pub automatic: bool,
@@ -178,8 +176,8 @@ pub struct Updater {
release: Option<Release>,
manual: Option<ManualUpdate>,
staged: Option<Staged>,
checked_at: Option<DateTime<Local>>,
automatic: bool,
channel: Channel,
download_failures: u32,
job: Option<Job>,
polling: bool,
@@ -205,8 +203,8 @@ impl Updater {
release: None,
manual: None,
staged: None,
checked_at: None,
automatic: true,
channel: Channel::Stable,
download_failures: 0,
job: None,
polling: false,
@@ -217,7 +215,7 @@ impl Updater {
pub fn set_services(&mut self, services: UpdateServices, cx: &mut Context<Self>) {
self.cancel_job();
self.phase = if services.channel.is_some() {
self.phase = if services.release_build {
Phase::Idle
} else {
Phase::Off
@@ -267,7 +265,6 @@ impl Updater {
.map_or_else(build_info::version, |services| services.current.clone()),
release: self.release.clone(),
manual: self.manual.clone(),
checked_at: self.checked_at,
unsupported: self.unsupported(),
automatic: self.automatic,
download_failures: self.download_failures,
@@ -283,7 +280,7 @@ impl Updater {
let Some(services) = &self.services else {
return Some(Unsupported::DevelopmentBuild);
};
if services.channel.is_none() {
if !services.release_build {
return Some(Unsupported::DevelopmentBuild);
}
match services.installer.kind() {
@@ -296,8 +293,9 @@ impl Updater {
self.unsupported().is_none()
}
/// 自动检查并下载更新. Turning it off stops a download in progress;
/// turning it on downloads a version already found.
/// 自动升级: look on a schedule and download what is found. Turning it
/// off stops a download in progress; turning it on downloads a version
/// already found.
pub fn set_automatic(&mut self, automatic: bool, cx: &mut Context<Self>) {
if self.automatic == automatic {
return;
@@ -312,6 +310,29 @@ impl Updater {
self.changed(cx);
}
/// 更新渠道. The other channel offers other versions, so what was found,
/// downloaded or is downloading is dropped, and the new channel is looked
/// at right away when looking is automatic.
pub fn set_channel(&mut self, channel: Channel, cx: &mut Context<Self>) {
if self.channel == channel {
return;
}
self.channel = channel;
if matches!(self.phase, Phase::Off | Phase::Installing) {
return;
}
self.cancel_job();
self.release = None;
self.manual = None;
self.staged = None;
self.download_failures = 0;
self.phase = Phase::Idle;
if self.automatic {
self.check(cx);
}
self.changed(cx);
}
/// Look for a newer version now.
pub fn check(&mut self, cx: &mut Context<Self>) {
if !matches!(
@@ -327,9 +348,10 @@ impl Updater {
let Some(services) = self.services.clone() else {
return;
};
let Some(channel) = services.channel else {
if !services.release_build {
return;
};
}
let channel = self.channel;
self.phase = Phase::Checking;
self.run(cx, move |events, _| {
let opened = services
@@ -603,7 +625,6 @@ impl Updater {
}
fn on_checked(&mut self, result: Result<Opened, UpdateError>, cx: &mut Context<Self>) {
self.checked_at = Some(Local::now());
let Some(services) = &self.services else {
return;
};
+6 -4
View File
@@ -104,11 +104,13 @@ impl Workspace {
}
}
/// 自动检查并下载更新, from the settings.
/// 自动升级 and 更新渠道, from the settings.
pub(super) fn sync_updater(&self, cx: &mut App) {
let automatic = self.settings().read(cx).settings().update.automatic;
self.updater
.update(cx, |updater, cx| updater.set_automatic(automatic, cx));
let settings = self.settings().read(cx).settings().update.clone();
self.updater.update(cx, |updater, cx| {
updater.set_automatic(settings.automatic, cx);
updater.set_channel(settings.channel, cx);
});
}
/// What restarting now would close or stop.
+3 -2
View File
@@ -299,10 +299,11 @@ impl Workspace {
);
let host_panel = cx.new(|cx| HostPanel::new(store.clone(), window, cx));
let forwards = cx.new(|cx| ForwardManager::new(store.clone(), forward_provider, cx));
let automatic = settings.read(cx).settings().update.automatic;
let update_settings = settings.read(cx).settings().update.clone();
let updater = cx.new(|cx| {
let mut updater = Updater::new();
updater.set_automatic(automatic, cx);
updater.set_automatic(update_settings.automatic, cx);
updater.set_channel(update_settings.channel, cx);
updater
});
let forward_panel = cx.new(|cx| {
+124 -41
View File
@@ -567,9 +567,7 @@ async fn a_host_without_a_password_says_what_it_tries_and_is_saved_as_such(
assert!(window.try_find("host-credential").is_none());
assert_eq!(
window.find("host-no-password-note").label(),
Some(
"依次尝试服务器免认证、SSH Agent 和 ~/.ssh 中的默认私钥;服务器要求密码时连接失败,不会询问。"
)
Some("依次尝试服务器免认证、SSH Agent 和 ~/.ssh 中的默认私钥。")
);
window.click("commit", cx);
});
@@ -642,9 +640,7 @@ async fn a_host_goes_through_the_jump_hosts_it_lists_in_order(cx: &mut TestAppCo
assert_eq!(chain(window).as_deref(), Some("本机 → 当前主机"));
assert_eq!(
window.find("host-route-note").label(),
Some(
"依次经过跳板主机连接到当前主机,可添加多台。跳板主机自己的「连接方式」在这里不生效。"
)
Some("依次经过跳板主机连接到当前主机,可添加多台。")
);
window.click("commit", cx);
});
@@ -9416,18 +9412,32 @@ const RUNNING_VERSION: &str = "0.1.0";
/// Where the fake installer says the restart goes.
const INSTALLED_BUNDLE: &str = "/Applications/ShellRS.app";
/// Serves one signed manifest and one package, and counts the requests.
/// Serves each channel's manifest, signed when asked for, and one package,
/// and counts the requests.
struct FakeUpdateFeed {
envelope: Mutex<Result<Vec<u8>, UpdateError>>,
pair: minisign::KeyPair,
/// What each channel offers, or the error asking for it gives. A
/// channel not listed answers 404.
offers: Mutex<Vec<(Channel, Result<String, UpdateError>)>>,
package: Vec<u8>,
fetches: AtomicUsize,
/// The channels asked for, in order.
channels: Mutex<Vec<Channel>>,
downloads: AtomicUsize,
}
impl UpdateFeed for FakeUpdateFeed {
fn fetch(&self, _: Channel) -> Result<Vec<u8>, UpdateError> {
fn fetch(&self, channel: Channel) -> Result<Vec<u8>, UpdateError> {
self.fetches.fetch_add(1, Ordering::SeqCst);
self.envelope.lock().unwrap().clone()
self.channels.lock().unwrap().push(channel);
let offer = self
.offers
.lock()
.unwrap()
.iter()
.find(|(offered, _)| *offered == channel)
.map_or(Err(UpdateError::Http(404)), |(_, offer)| offer.clone());
offer.map(|version| signed_manifest(&self.pair, channel, &version, &self.package))
}
fn download(
@@ -9518,10 +9528,27 @@ impl UpdateFixture {
fn downloads(&self) -> usize {
self.feed.downloads.load(Ordering::SeqCst)
}
fn channels(&self) -> Vec<Channel> {
self.feed.channels.lock().unwrap().clone()
}
/// Have `channel` offer `version` from now on.
fn offer(&self, channel: Channel, version: &str) {
let mut offers = self.feed.offers.lock().unwrap();
offers.retain(|(offered, _)| *offered != channel);
offers.push((channel, Ok(version.to_string())));
}
}
/// A stable manifest offering `version` for this platform, signed by `pair`.
fn signed_manifest(pair: &minisign::KeyPair, version: &str, package: &[u8]) -> Vec<u8> {
/// `channel`'s manifest offering `version` for this platform, signed by
/// `pair`.
fn signed_manifest(
pair: &minisign::KeyPair,
channel: Channel,
version: &str,
package: &[u8],
) -> Vec<u8> {
use sha2::Digest as _;
let platform = shellrs::update::platform::platform_key();
let sha256: String = sha2::Sha256::digest(package)
@@ -9530,7 +9557,7 @@ fn signed_manifest(pair: &minisign::KeyPair, version: &str, package: &[u8]) -> V
.collect();
let manifest = serde_json::json!({
"schema": 1,
"channel": "stable",
"channel": channel.key(),
"version": version,
"published_at": "2026-10-20T08:00:00Z",
"notes": "### 新增\n\n- 在线升级:新版本在后台下载好后,标题栏会提示。",
@@ -9548,7 +9575,7 @@ fn signed_manifest(pair: &minisign::KeyPair, version: &str, package: &[u8]) -> V
Some(&pair.pk),
&pair.sk,
std::io::Cursor::new(manifest.as_bytes()),
Some(&format!("shellrs-manifest stable {version}")),
Some(&format!("shellrs-manifest {} {version}", channel.key())),
None,
)
.unwrap()
@@ -9557,8 +9584,9 @@ fn signed_manifest(pair: &minisign::KeyPair, version: &str, package: &[u8]) -> V
.unwrap()
}
/// Give the workspace's updater a fake server offering `offered` (or
/// answering `error`) and a fake installer of `kind`, as a stable 0.1.0.
/// Give the workspace's updater a fake server whose stable channel offers
/// `offered` (or answers `error`) and a fake installer of `kind`, as a
/// release build of 0.1.0.
fn serve_updates(
cx: &mut TestAppContext,
workspace: &Entity<Workspace>,
@@ -9566,12 +9594,16 @@ fn serve_updates(
kind: InstallKind,
) -> UpdateFixture {
let pair = minisign::KeyPair::generate_unencrypted_keypair().unwrap();
let package = b"the new ShellRS".to_vec();
let envelope = offered.map(|version| signed_manifest(&pair, version, &package));
let keys = TrustedKeys::new([pair.pk.to_base64().as_str()]);
let feed = Arc::new(FakeUpdateFeed {
envelope: Mutex::new(envelope),
package,
pair,
offers: Mutex::new(vec![(
Channel::Stable,
offered.map(|version| version.to_string()),
)]),
package: b"the new ShellRS".to_vec(),
fetches: AtomicUsize::new(0),
channels: Mutex::default(),
downloads: AtomicUsize::new(0),
});
let installer = FakeInstaller::new(kind);
@@ -9579,8 +9611,8 @@ fn serve_updates(
let services = UpdateServices {
feed: feed.clone(),
installer: installer.clone(),
keys: TrustedKeys::new([pair.pk.to_base64().as_str()]),
channel: Some(Channel::Stable),
keys,
release_build: true,
current: Version::parse(RUNNING_VERSION).unwrap(),
folder: folder.path().to_path_buf(),
draw: 0.5,
@@ -9638,6 +9670,22 @@ fn set_automatic_updates(cx: &mut TestAppContext, workspace: &Entity<Workspace>,
cx.run_until_parked();
}
/// Write 更新渠道 the way its dropdown does (menus are not driven here, see
/// `appearance_dropdown`).
fn set_update_channel(cx: &mut TestAppContext, workspace: &Entity<Workspace>, channel: Channel) {
workspace.update(cx, |workspace, cx| {
workspace.settings().update(cx, |settings, cx| {
settings.update(|settings| settings.update.channel = channel, cx)
});
});
cx.run_until_parked();
}
/// The 更新渠道 dropdown's label on 设置 › 关于.
fn update_channel_label(cx: &mut TestAppContext, handle: WindowHandle<Root>) -> Option<String> {
in_frame(cx, handle, |window, _| appearance_dropdown(window, 1))
}
#[gpui_kit::test]
fn a_development_build_does_not_check(cx: &mut TestAppContext) {
let (handle, _) = open_workspace(cx);
@@ -9645,15 +9693,13 @@ fn a_development_build_does_not_check(cx: &mut TestAppContext) {
in_frame(cx, handle, |window, _| {
assert_eq!(
window.find("update-status").label(),
Some("开发构建,不检查更新")
Some("开发构建,不检查更新。")
);
assert!(window.try_find("check-for-updates").is_none());
let version = window
.find("about-version")
.label()
.unwrap_or_default()
.to_string();
assert!(version.starts_with(env!("CARGO_PKG_VERSION")), "{version}");
assert_eq!(
window.find("about-version").label(),
Some(format!("v{}", env!("CARGO_PKG_VERSION")).as_str())
);
assert!(window.try_find("update-available").is_none());
});
}
@@ -9663,15 +9709,12 @@ async fn checking_by_hand_says_when_shellrs_is_up_to_date(cx: &mut TestAppContex
let (handle, workspace) = open_workspace(cx);
let fixture = serve_updates(cx, &workspace, Ok(RUNNING_VERSION), installable());
open_about_settings(cx, handle);
assert_eq!(update_status(cx, handle), "尚未检查更新");
assert_eq!(update_status(cx, handle), "尚未检查更新。");
in_frame(cx, handle, |window, cx| {
window.click("check-for-updates", cx)
});
wait_for_update_status(cx, handle, |status| {
status.starts_with("已是最新版本 · 上次检查 ")
})
.await;
wait_for_update_status(cx, handle, |status| status == "当前已是最新版本。").await;
assert_eq!(fixture.fetches(), 1);
assert_eq!(fixture.downloads(), 0);
in_frame(cx, handle, |window, _| {
@@ -9701,11 +9744,12 @@ async fn a_found_update_downloads_by_itself_and_the_title_bar_offers_it(cx: &mut
);
assert_eq!(
update_status(cx, handle),
"0.2.0 已就绪,退出或重启 ShellRS 时安装"
"新版本 0.2.0 已下载,重启 ShellRS 即可完成更新。"
);
in_frame(cx, handle, |window, _| {
// One button: restarting takes the place of 检查更新.
assert!(window.try_find("show-update").is_some());
// The release notes are on the page too.
assert!(window.try_find("check-for-updates").is_none());
assert!(window.try_find("download-update").is_none());
});
}
@@ -9720,14 +9764,17 @@ async fn with_automatic_updates_off_a_found_update_waits_for_download(cx: &mut T
in_frame(cx, handle, |window, cx| {
window.click("check-for-updates", cx)
});
wait_for_update_status(cx, handle, |status| status == "发现新版本 0.2.0").await;
wait_for_update_status(cx, handle, |status| status == "发现新版本 0.2.0。").await;
assert_eq!(fixture.downloads(), 0);
in_frame(cx, handle, |window, _| {
assert!(window.try_find("update-available").is_none());
});
in_frame(cx, handle, |window, cx| window.click("download-update", cx));
wait_for_update_status(cx, handle, |status| status.starts_with("0.2.0 已就绪")).await;
wait_for_update_status(cx, handle, |status| {
status.starts_with("新版本 0.2.0 已下载")
})
.await;
assert_eq!(fixture.downloads(), 1);
}
@@ -9848,7 +9895,7 @@ async fn an_unsupported_install_offers_the_download_page(cx: &mut TestAppContext
window.click("check-for-updates", cx)
});
wait_for_update_status(cx, handle, |status| {
status == "发现新版本 0.2.0。这份 ShellRS 不是 AppImage,请用安装它的方式更新"
status == "发现新版本 0.2.0。这份 ShellRS 不是 AppImage,请用安装它的方式更新。"
})
.await;
assert_eq!(fixture.downloads(), 0);
@@ -9871,7 +9918,7 @@ async fn a_failed_check_says_why_on_the_about_page(cx: &mut TestAppContext) {
window.click("check-for-updates", cx)
});
wait_for_update_status(cx, handle, |status| {
status == "检查失败:更新服务器返回 HTTP 503"
status == "检查更新失败:更新服务器返回 HTTP 503"
})
.await;
assert_eq!(fixture.fetches(), 1);
@@ -9895,7 +9942,7 @@ async fn turning_automatic_updates_off_stops_the_schedule(cx: &mut TestAppContex
cx.executor().advance_clock(Duration::from_secs(31));
open_about_settings(cx, handle);
wait_for_update_status(cx, handle, |status| status.starts_with("已是最新版本")).await;
wait_for_update_status(cx, handle, |status| status == "当前已是最新版本。").await;
assert_eq!(fixture.fetches(), 1);
set_automatic_updates(cx, &workspace, false);
@@ -9913,6 +9960,42 @@ async fn turning_automatic_updates_off_stops_the_schedule(cx: &mut TestAppContex
.await;
}
#[gpui_kit::test]
async fn switching_the_update_channel_looks_at_that_channel(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace(cx);
let fixture = serve_updates(cx, &workspace, Ok(RUNNING_VERSION), installable());
fixture.offer(Channel::Beta, "0.2.0-beta.1");
open_about_settings(cx, handle);
assert_eq!(update_channel_label(cx, handle).as_deref(), Some("稳定版"));
in_frame(cx, handle, |window, cx| {
window.click("check-for-updates", cx)
});
wait_for_update_status(cx, handle, |status| status == "当前已是最新版本。").await;
// With 自动升级 on, the other channel is looked at right away.
set_update_channel(cx, &workspace, Channel::Beta);
wait_for_update_status(cx, handle, |status| {
status == "新版本 0.2.0-beta.1 已下载,重启 ShellRS 即可完成更新。"
})
.await;
assert_eq!(fixture.channels(), [Channel::Stable, Channel::Beta]);
assert_eq!(update_channel_label(cx, handle).as_deref(), Some("Beta"));
in_frame(cx, handle, |window, _| {
assert!(window.try_find("update-available").is_some());
});
// Back on stable, the beta downloaded is no longer offered.
set_update_channel(cx, &workspace, Channel::Stable);
wait_for_update_status(cx, handle, |status| status == "当前已是最新版本。").await;
assert_eq!(
fixture.channels(),
[Channel::Stable, Channel::Beta, Channel::Stable]
);
in_frame(cx, handle, |window, _| {
assert!(window.try_find("update-available").is_none());
});
}
#[gpui_kit::test]
async fn the_first_start_after_an_update_says_so(cx: &mut TestAppContext) {
let (handle, workspace) = open_workspace(cx);