diff --git a/CHANGELOG.md b/CHANGELOG.md index 52c373c..f1e0eab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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] diff --git a/CLAUDE.md b/CLAUDE.md index 6c9e06b..1360a77 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`),连接时直接报错,不悄悄改成直连或少跳一级,表单里留着占位就保存会报错;删除不重连已打开的连接。在线升级:主域名 shellrs.com,安装包和更新清单放在 Cloudflare R2(`dl.shellrs.com`,GitHub Release 留一份),客户端只写死 `https://dl.shellrs.com/update/v1/{channel}.json`,这个地址、平台 key 的名字和公钥槽位一经发布就不能改;macOS(Apple Silicon 和 Intel 共用一个 universal 包,Developer ID 签名加公证)、Windows x64(按用户安装的 Inno Setup 安装程序,暂不签名)和 Linux x64(AppImage)都在应用内更新;默认在后台检查**并**下载,下好、校验完才在标题栏右上角亮图标,点开是更新对话框(更新内容、重启会中断什么、「重启更新」,不叠第二层确认框);不点也在退出时装好;「设置 › 关于」看版本、手动检查、关掉「自动检查并下载更新」;检查只发 User-Agent(版本、系统、架构),不发任何安装标识。 +已确认的产品决定: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`),连接时直接报错,不悄悄改成直连或少跳一级,表单里留着占位就保存会报错;删除不重连已打开的连接。在线升级:主域名 shellrs.com,安装包和更新清单放在 Cloudflare R2(`dl.shellrs.com`,GitHub Release 留一份),客户端只写死 `https://dl.shellrs.com/update/v1/{channel}.json`,这个地址、平台 key 的名字和公钥槽位一经发布就不能改;macOS(Apple Silicon 和 Intel 共用一个 universal 包,Developer ID 签名加公证)、Windows x64(按用户安装的 Inno Setup 安装程序,暂不签名)和 Linux x64(AppImage)都在应用内更新;默认在后台检查**并**下载,下好、校验完才在标题栏右上角亮图标,点开是更新对话框(更新内容、重启会中断什么、「重启并安装」,不叠第二层确认框);不点也在退出时装好;「设置 › 关于」只有「应用更新」一组三行(用户给的样式):「当前版本」(下面一行是状态,右边是 `v版本号` 和一个按钮:平时「检查更新」,有事要做时换成「重启并安装…」「下载更新」或「前往下载页」,不并排两个)、「更新渠道」(稳定版 / Beta,运行时切换,默认是这个构建自己的渠道;切换就丢掉已找到、已下好的版本,开着自动升级时立刻查新渠道)、「自动升级」开关;构建号和平台只在版本号的 tooltip 里;检查只发 User-Agent(版本、系统、架构),不发任何安装标识。 做 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/.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: }`,一个文件、一次请求,不会出现清单和签名缓存不同步;`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`:生产环境由 `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-` 与 `skill-status-`(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`、上传 / 下载确认框的首行 `transfer-summary`(`aria_label` 形如「上传 2 个文件到 …」)与来源清单的行 `source:<完整路径>`(`aria_label` 是名称;清单只写一次共同目录,每项单独一行,长路径不会折成两行看起来像两项)、路径标签 `local-path` / `remote-path`(`aria_value` 是已读入的目录,读取中不报,所以可以拿它等列表;`aria_selected` 表示是当前面板)与其中的 `path-parts`、各级 `path:<目录>`、「打开目录/书签」的 `open-directory-path` / `open-directory-browse` / `open-directory-confirm`、书签列表 `bookmark-list` 与行 `bookmark:<路径>`(`aria_selected`)、`bookmark-add` / `bookmark-remove` / `bookmark-up` / `bookmark-down`、列表下方的状态栏 `pane-status`(`Role::Status`,`aria_label` 是显示的文字)、SFTP 标签栏的 `("open-sftp", id)` 与 `("reconnect-sftp", id)`、窗口状态栏的 `status-connection`(`aria_label` 是显示的文字;SFTP 标签在前时是「已连接 db-01」「未连接 db-01:原因」或读目录的错误原因)、传输队列 `("transfer-queue", explorer_id)`(为空时不渲染)与工具栏的 `resume-transfer` / `cancel-transfer` / `remove-transfer` / `discard-transfer` / `clear-finished-transfers`(按钮一直都在,不可用时是禁用状态,所以测试不能拿「按钮出现」当「传输开始」)、批次行 `("queue-entry", 队列序号)`(`aria_label` 是进度列的文字,如「等待中」「29%」「已停止」「已完成」;`aria_selected` 表示选中)、队首批次的进度列 `transfer-status`(`aria_label` 同上,测试用 `wait_for_head_status` 等它)、其余批次的 `("queue-status", 序号)`、当前文件子行 `("queue-file", 序号)`(`aria_label` 是源路径)与它的 `("queue-file-status", 序号)`(百分比)、展开按钮 `("queue-expand", 序号)`、展开后的 `("transfer-detail-list", 序号)`、`transfer-detail-empty` 与 `transfer-detail:<路径>`、确认框里的排队提示 `transfer-queued-note`。假传输 `FakeSftpTransport` 像真引擎一样在停止和完成后发 `Idle`,队列靠它推进、问答的 `transfer-question-*` 与 `transfer-apply-all`。按钮的禁用状态 gpui-base 不报给无障碍树,测试里用「是否还能取得焦点」判断(`focused().is_some()`);`TestWindowExt::click` 不带修饰键,⌘ / Shift 单击用 `modified_click` 助手直接发鼠标事件。GPUI 只给激活的窗口发焦点事件(`on_focus_in`、Input 的 Focus / Blur),测试窗口默认不激活,要测这些先 `window.activate_window()` 再 `run_until_parked`;按上一帧宽度重排的元素(路径标签)要 `window.simulate_next_frame(cx)` 才会重画。夹具(`open_workspace` / `open_workspace_with_store` / `open_workspace_with_tester`,以及 SFTP、转发、凭据各自的夹具)都会 `set_reduce_motion(true)`:对话框按真实时间滑入,测试并行、机器忙时字段和按钮会在定位和点击之间移位,点空之后要等的东西永远不来(`new_host_dialog_validates_then_inserts`、`renaming_a_group_keeps_the_hosts_under_it` 都这样偶发失败过)。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。端口转发相关的有:转发列表的 `forward-search`、`forward-list`、空状态 `forward-empty`(`aria_label` 是标题)与其中的 `forward-empty-new` / `forward-empty-new-host`、Dock 工具栏的 `new-forward`、行 `("forward-row", id)`、行尾的状态 `("forward-status", id)`(`aria_label` 是「已停止」「正在连接」「运行中」「运行中,3 个连接」「连接中断,正在重连(1/3)」或「已停止:原因」,测试用 `wait_for_forward_status` 等它)和启停按钮 `("forward-toggle", id)`;对话框的类型卡片在 `within("forward-kind")` 里按序号 `0usize` / `1usize` / `2usize`(`RadioGroup` 会把子项的 id 改成序号)、`forward-diagram`(`aria_label` 形如「本机 → SSH 服务器 → 目标服务」)、流向圆点 `forward-flow-dot`(在某一段线上时才存在,每圈末尾停顿时没有)、`forward-explanation`(`aria_label` 是那句说明)、卡片里的用途 `("forward-purpose", 序号)`、`forward-name` / `forward-host` / `forward-bind-host` / `forward-bind-port` / `forward-target-host` / `forward-target-port`、`forward-auto-start`、`forward-notes`(有提示时才存在)。假传输 `FakeForwardProvider` 不开任何套接字,记录每次运行(`runs()`,可以往 `events` 里送真实转发之后才会报的事件),像真的一样用 `Stopped` 回应 `Stop`;`ForwardScript` 选它启动后的行为(监听、先问主机密钥、直接失败)。转发的夹具 `open_workspace_with_forwards` 和 SFTP 的一样 `set_reduce_motion(true)`,示意图因此是静止的、没有圆点;要测动画本身就在夹具之后 `set_reduce_motion(false)`(`the_forward_diagram_keeps_showing_its_flow` 这样做,再用 `advance_clock` 推进并比较圆点的位置)。凭据相关的有:标题栏的 `show-credentials`、列表的 `credential-search`、`credential-list`、空状态 `credential-empty`(`aria_label` 是标题)与其中的 `credential-empty-new` / `credential-empty-generate`(「生成密钥…」)、Dock 工具栏的 `new-credential`、行 `("credential-row", id)`(`aria_selected` 表示选中);凭据对话框的类型在 `within("credential-kind")` 里按序号 `0usize`(密码)/ `1usize`(密钥)/ `2usize`(SSH Agent),密钥的来源在 `within("credential-key-source")` 里按序号 `0usize`(本机文件)/ `1usize`(粘贴)/ `2usize`(生成新密钥),算法在 `within("credential-key-algorithm")` 里,字段是 `credential-name` / `credential-user` / `credential-password` / `credential-key-path` / `choose-credential-key` / `credential-passphrase`、粘贴框 `credential-key-text`(外面包的 `div`;测试里先往剪贴板写再 `cmd-v`),公钥 `credential-public-key`(`aria_label` 是整行公钥)与它的复制按钮 `copy-public-key`、生成中的 `credential-public-key-pending`、生成失败的 `credential-generate-error`,保存会删掉 ShellRS 保存的原私钥时的提示 `credential-kept-key-note`,加密中的 `credential-saving`,Agent 的说明 `credential-agent-note`,编辑时的用量 `credential-usage`(`aria_label` 形如「有 1 台主机使用此凭据。…」);凭据测试要 ShellRS 保存私钥时用 `store_keeping_keys` 把目录放进临时目录;主机对话框的认证方式(和凭据对话框的分段控件一样,各段 `selected()` 表示选中)在 `within("host-auth-source")` 里按序号 `0usize`(密码)/ `1usize`(使用凭据)/ `2usize`(无密码),凭据下拉 `host-credential`(`value` 是「名称(用户 · 类型)」或占位「请选择凭据」)与它下面的说明 `host-credential-summary`;连接方式在 `within("host-route")` 里按序号 `0usize`(直接连接)/ `1usize`(SSH 跳板)/ `2usize`(代理连接),框里的说明 `host-route-note`,跳板的链 `host-route-chain`(`aria_label` 形如「本机 → 阿里云99 → 当前主机」)、每台一行 `("jump-hop", 主机 id)`(`aria_label` 是名称;已删除的是 `("jump-hop-deleted", 序号)`)、行尾的移除按钮 `("remove-jump-hop", 序号)`、添加用的 `Combobox` 的触发区 `jump-add-trigger`(gpui-component 的 `Combobox` 自己的 `input` 没有 `test_support`,所以触发区是我们画的、带 id;测试里点它、输入名称或地址搜索、回车,`add_jump_host` 助手就是这样;选中后表单清掉选择和搜索词再把剩下的主机放回去),代理的 `host-proxy-kind`(`Select`)/ `host-proxy-host` / `host-proxy-port` / `host-proxy-user` / `host-proxy-password`,最后的备注 `host-notes`(多行 `Textarea` 外面包的 `div`,点它再输入;回车换行,不提交对话框)。`FakeConnectionTester::routes()` 记下每次测试连接的 `LoginRoute` 和表单给的代理密码。凭据测试的夹具 `open_workspace_with_credentials` 关掉动效,远程终端用 `RecordingRemoteProvider` 记下每次连接拿到的 `HostLogin`(重连也记一次,所以能拿它数重连)。在线升级相关的有:「关于」页的 `about-version` / `about-build` / `about-platform`(`aria_label` 是显示的文字)、状态行 `update-status`(`aria_label` 是显示的文字,测试用 `wait_for_update_status` 等它)、按钮 `check-for-updates` / `download-update` / `show-update` / `open-download-page`、开关在 `group-1` › `item-1` › `check`;标题栏的 `update-available`(`aria_label` 是「新版本 0.2.0 已就绪」之类);更新对话框 `update-dialog` 里的 `update-notes`(`aria_label` 是更新说明原文)、`update-progress`、`update-problem`、`update-restart-note` 和按钮 `restart-to-update` / `download-update` / `open-download-page`。`serve_updates` 给更新器装上 `FakeUpdateFeed`(用当场生成的 minisign 密钥签好的清单,测试按 0.1.0 运行)和 `FakeInstaller`(只记录 stage / apply,重启路径是 `/Applications/ShellRS.app`),重启用 `cx.expect_restart()` 断言,退出时安装用 `cx.update(|cx| cx.shutdown())` 触发。种子主机 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 +UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity`:生产环境由 `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-` 与 `skill-status-`(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`、上传 / 下载确认框的首行 `transfer-summary`(`aria_label` 形如「上传 2 个文件到 …」)与来源清单的行 `source:<完整路径>`(`aria_label` 是名称;清单只写一次共同目录,每项单独一行,长路径不会折成两行看起来像两项)、路径标签 `local-path` / `remote-path`(`aria_value` 是已读入的目录,读取中不报,所以可以拿它等列表;`aria_selected` 表示是当前面板)与其中的 `path-parts`、各级 `path:<目录>`、「打开目录/书签」的 `open-directory-path` / `open-directory-browse` / `open-directory-confirm`、书签列表 `bookmark-list` 与行 `bookmark:<路径>`(`aria_selected`)、`bookmark-add` / `bookmark-remove` / `bookmark-up` / `bookmark-down`、列表下方的状态栏 `pane-status`(`Role::Status`,`aria_label` 是显示的文字)、SFTP 标签栏的 `("open-sftp", id)` 与 `("reconnect-sftp", id)`、窗口状态栏的 `status-connection`(`aria_label` 是显示的文字;SFTP 标签在前时是「已连接 db-01」「未连接 db-01:原因」或读目录的错误原因)、传输队列 `("transfer-queue", explorer_id)`(为空时不渲染)与工具栏的 `resume-transfer` / `cancel-transfer` / `remove-transfer` / `discard-transfer` / `clear-finished-transfers`(按钮一直都在,不可用时是禁用状态,所以测试不能拿「按钮出现」当「传输开始」)、批次行 `("queue-entry", 队列序号)`(`aria_label` 是进度列的文字,如「等待中」「29%」「已停止」「已完成」;`aria_selected` 表示选中)、队首批次的进度列 `transfer-status`(`aria_label` 同上,测试用 `wait_for_head_status` 等它)、其余批次的 `("queue-status", 序号)`、当前文件子行 `("queue-file", 序号)`(`aria_label` 是源路径)与它的 `("queue-file-status", 序号)`(百分比)、展开按钮 `("queue-expand", 序号)`、展开后的 `("transfer-detail-list", 序号)`、`transfer-detail-empty` 与 `transfer-detail:<路径>`、确认框里的排队提示 `transfer-queued-note`。假传输 `FakeSftpTransport` 像真引擎一样在停止和完成后发 `Idle`,队列靠它推进、问答的 `transfer-question-*` 与 `transfer-apply-all`。按钮的禁用状态 gpui-base 不报给无障碍树,测试里用「是否还能取得焦点」判断(`focused().is_some()`);`TestWindowExt::click` 不带修饰键,⌘ / Shift 单击用 `modified_click` 助手直接发鼠标事件。GPUI 只给激活的窗口发焦点事件(`on_focus_in`、Input 的 Focus / Blur),测试窗口默认不激活,要测这些先 `window.activate_window()` 再 `run_until_parked`;按上一帧宽度重排的元素(路径标签)要 `window.simulate_next_frame(cx)` 才会重画。夹具(`open_workspace` / `open_workspace_with_store` / `open_workspace_with_tester`,以及 SFTP、转发、凭据各自的夹具)都会 `set_reduce_motion(true)`:对话框按真实时间滑入,测试并行、机器忙时字段和按钮会在定位和点击之间移位,点空之后要等的东西永远不来(`new_host_dialog_validates_then_inserts`、`renaming_a_group_keeps_the_hosts_under_it` 都这样偶发失败过)。被遮蔽的输入框在测试里读不到值(`ElementSnapshot::value()` 返回 `None`),所以密码相关的断言一律查注入的 `InMemorySecretStore`,不查界面。动作派发和 `set_active` 都是延迟执行的:先退出 `update_window`,调用 `cx.run_until_parked()`,再 `render_frame` 后断言。端口转发相关的有:转发列表的 `forward-search`、`forward-list`、空状态 `forward-empty`(`aria_label` 是标题)与其中的 `forward-empty-new` / `forward-empty-new-host`、Dock 工具栏的 `new-forward`、行 `("forward-row", id)`、行尾的状态 `("forward-status", id)`(`aria_label` 是「已停止」「正在连接」「运行中」「运行中,3 个连接」「连接中断,正在重连(1/3)」或「已停止:原因」,测试用 `wait_for_forward_status` 等它)和启停按钮 `("forward-toggle", id)`;对话框的类型卡片在 `within("forward-kind")` 里按序号 `0usize` / `1usize` / `2usize`(`RadioGroup` 会把子项的 id 改成序号)、`forward-diagram`(`aria_label` 形如「本机 → SSH 服务器 → 目标服务」)、流向圆点 `forward-flow-dot`(在某一段线上时才存在,每圈末尾停顿时没有)、`forward-explanation`(`aria_label` 是那句说明)、卡片里的用途 `("forward-purpose", 序号)`、`forward-name` / `forward-host` / `forward-bind-host` / `forward-bind-port` / `forward-target-host` / `forward-target-port`、`forward-auto-start`、`forward-notes`(有提示时才存在)。假传输 `FakeForwardProvider` 不开任何套接字,记录每次运行(`runs()`,可以往 `events` 里送真实转发之后才会报的事件),像真的一样用 `Stopped` 回应 `Stop`;`ForwardScript` 选它启动后的行为(监听、先问主机密钥、直接失败)。转发的夹具 `open_workspace_with_forwards` 和 SFTP 的一样 `set_reduce_motion(true)`,示意图因此是静止的、没有圆点;要测动画本身就在夹具之后 `set_reduce_motion(false)`(`the_forward_diagram_keeps_showing_its_flow` 这样做,再用 `advance_clock` 推进并比较圆点的位置)。凭据相关的有:标题栏的 `show-credentials`、列表的 `credential-search`、`credential-list`、空状态 `credential-empty`(`aria_label` 是标题)与其中的 `credential-empty-new` / `credential-empty-generate`(「生成密钥…」)、Dock 工具栏的 `new-credential`、行 `("credential-row", id)`(`aria_selected` 表示选中);凭据对话框的类型在 `within("credential-kind")` 里按序号 `0usize`(密码)/ `1usize`(密钥)/ `2usize`(SSH Agent),密钥的来源在 `within("credential-key-source")` 里按序号 `0usize`(本机文件)/ `1usize`(粘贴)/ `2usize`(生成新密钥),算法在 `within("credential-key-algorithm")` 里,字段是 `credential-name` / `credential-user` / `credential-password` / `credential-key-path` / `choose-credential-key` / `credential-passphrase`、粘贴框 `credential-key-text`(外面包的 `div`;测试里先往剪贴板写再 `cmd-v`),公钥 `credential-public-key`(`aria_label` 是整行公钥)与它的复制按钮 `copy-public-key`、生成中的 `credential-public-key-pending`、生成失败的 `credential-generate-error`,保存会删掉 ShellRS 保存的原私钥时的提示 `credential-kept-key-note`,加密中的 `credential-saving`,Agent 的说明 `credential-agent-note`,编辑时的用量 `credential-usage`(`aria_label` 形如「有 1 台主机使用此凭据。…」);凭据测试要 ShellRS 保存私钥时用 `store_keeping_keys` 把目录放进临时目录;主机对话框的认证方式(和凭据对话框的分段控件一样,各段 `selected()` 表示选中)在 `within("host-auth-source")` 里按序号 `0usize`(密码)/ `1usize`(使用凭据)/ `2usize`(无密码),凭据下拉 `host-credential`(`value` 是「名称(用户 · 类型)」或占位「请选择凭据」)与它下面的说明 `host-credential-summary`;连接方式在 `within("host-route")` 里按序号 `0usize`(直接连接)/ `1usize`(SSH 跳板)/ `2usize`(代理连接),框里的说明 `host-route-note`,跳板的链 `host-route-chain`(`aria_label` 形如「本机 → 阿里云99 → 当前主机」)、每台一行 `("jump-hop", 主机 id)`(`aria_label` 是名称;已删除的是 `("jump-hop-deleted", 序号)`)、行尾的移除按钮 `("remove-jump-hop", 序号)`、添加用的 `Combobox` 的触发区 `jump-add-trigger`(gpui-component 的 `Combobox` 自己的 `input` 没有 `test_support`,所以触发区是我们画的、带 id;测试里点它、输入名称或地址搜索、回车,`add_jump_host` 助手就是这样;选中后表单清掉选择和搜索词再把剩下的主机放回去),代理的 `host-proxy-kind`(`Select`)/ `host-proxy-host` / `host-proxy-port` / `host-proxy-user` / `host-proxy-password`,最后的备注 `host-notes`(多行 `Textarea` 外面包的 `div`,点它再输入;回车换行,不提交对话框)。`FakeConnectionTester::routes()` 记下每次测试连接的 `LoginRoute` 和表单给的代理密码。凭据测试的夹具 `open_workspace_with_credentials` 关掉动效,远程终端用 `RecordingRemoteProvider` 记下每次连接拿到的 `HostLogin`(重连也记一次,所以能拿它数重连)。在线升级相关的有:「关于」页的版本号 `about-version`(`aria_label` 是「v0.1.0」)、「当前版本」下面的状态行 `update-status`(`aria_label` 是显示的文字,测试用 `wait_for_update_status` 等它)、版本号右边同一时间只有一个的按钮 `check-for-updates` / `download-update` / `show-update` / `open-download-page`、「更新渠道」下拉在 `group-0` › `item-1` › `btn`(测试用 `set_update_channel` 写设置,不点菜单)、「自动升级」开关在 `group-0` › `item-2` › `check`;假 feed 按渠道给清单(`fixture.offer(Channel::Beta, ..)`),`fixture.channels()` 记下查过的渠道;标题栏的 `update-available`(`aria_label` 是「新版本 0.2.0 已就绪」之类);更新对话框 `update-dialog` 里的 `update-notes`(`aria_label` 是更新说明原文)、`update-progress`、`update-problem`、`update-restart-note` 和按钮 `restart-to-update` / `download-update` / `open-download-page`。`serve_updates` 给更新器装上 `FakeUpdateFeed`(用当场生成的 minisign 密钥签好的清单,测试按 0.1.0 运行)和 `FakeInstaller`(只记录 stage / apply,重启路径是 `/Applications/ShellRS.app`),重启用 `cx.expect_restart()` 断言,退出时安装用 `cx.update(|cx| cx.shutdown())` 触发。种子主机 id 按插入顺序(`web-01`=1 … `dev-box`=6),种子分组同理(`生产`=1、`测试`=2、`开发`=3);`web-01` 与 `staging-api` 初始为已连接并已打开终端标签。 diff --git a/README.md b/README.md index ab7fc07..7eb0892 100644 --- a/README.md +++ b/README.md @@ -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 <远程路径> <本地路径> | Windows x64 | 安装程序,按当前用户安装到 `%LOCALAPPDATA%\Programs\ShellRS`,不需要管理员 | 支持 | | Linux x64 | AppImage | 支持 | -ShellRS 启动 30 秒后检查一次有没有新版本,之后每 6 小时检查一次。有新版本就在后台下载并校验,完成后标题栏右上角出现更新图标。点开可以看更新内容,点「重启更新」就换成新版本并重新打开;不点也没关系,退出 ShellRS 时会装好,下次打开就是新版本。重启会断开已打开的连接、停止传输(续传进度保留)和端口转发,对话框里会写明具体有哪些。 +ShellRS 启动 30 秒后检查一次有没有新版本,之后每 6 小时检查一次。有新版本就在后台下载并校验,完成后标题栏右上角出现更新图标。点开可以看更新内容,点「重启并安装」就换成新版本并重新打开;不点也没关系,退出 ShellRS 时会装好,下次打开就是新版本。重启会断开已打开的连接、停止传输(续传进度保留)和端口转发,对话框里会写明具体有哪些。 -「设置 › 关于」可以随时检查更新,看下载进度和更新内容,也可以关掉「自动检查并下载更新」。关掉之后只在手动检查时才联网,发现新版本时由你决定是否下载。 +「设置 › 关于」里的「应用更新」只有三行: + +- **当前版本**:下面写着现在的状态(已是最新、正在下载、已下载等),右边是版本号和一个按钮:平时是「检查更新」,下载好之后变成「重启并安装…」。构建号和平台在版本号的悬停提示里。 +- **更新渠道**:稳定版适合日常使用;Beta 可以提前收到测试版本,稳定版发布时也会收到。切换后马上按新渠道检查(关掉自动升级时等你手动检查)。 +- **自动升级**:关掉之后只在手动检查时才联网,发现新版本时由你决定是否下载。 - **校验**:更新清单由 ShellRS 的发布密钥签名,安装包按清单里的大小和 SHA-256 核对,任何一步对不上都不会安装。macOS 上还会核对新版本的开发者签名。 - **隐私**:检查更新只发送 ShellRS 的版本号、操作系统和 CPU 架构,不发送任何能识别这台电脑或用户的信息。 diff --git a/src/host/host_dialog.rs b/src/host/host_dialog.rs index 212e863..f887b7a 100644 --- a/src/host/host_dialog.rs +++ b/src/host/host_dialog.rs @@ -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 = "目标地址由代理服务器解析;代理不需要认证时,用户名和密码留空。"; diff --git a/src/settings/model.rs b/src/settings/model.rs index ecc0c8a..da49e1a 100644 --- a/src/settings/model.rs +++ b/src/settings/model.rs @@ -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 { diff --git a/src/settings/settings_panel.rs b/src/settings/settings_panel.rs index 496fd2b..60c63f7 100644 --- a/src/settings/settings_panel.rs +++ b/src/settings/settings_panel.rs @@ -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 { ] } -/// 关于: 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 { 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 { ) .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)> = - 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) { + 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. diff --git a/src/update/build_info.rs b/src/update/build_info.rs index 0992662..2c949ec 100644 --- a/src/update/build_info.rs +++ b/src/update/build_info.rs @@ -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, diff --git a/src/update/mod.rs b/src/update/mod.rs index b584033..267ca13 100644 --- a/src/update/mod.rs +++ b/src/update/mod.rs @@ -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, diff --git a/src/update/status.rs b/src/update/status.rs index ba3b7bc..4d58265 100644 --- a/src/update/status.rs +++ b/src/update/status.rs @@ -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 { @@ -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 { + 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] diff --git a/src/update/update_dialog.rs b/src/update/update_dialog.rs index 5b2ec97..35fd04a 100644 --- a/src/update/update_dialog.rs +++ b/src/update/update_dialog.rs @@ -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)> = - 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) { + 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))) diff --git a/src/update/updater.rs b/src/update/updater.rs index 97795b4..1589d6b 100644 --- a/src/update/updater.rs +++ b/src/update/updater.rs @@ -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, pub installer: Arc, pub keys: TrustedKeys, - /// `None` for a development build, which never looks. - pub channel: Option, + /// `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, pub manual: Option, - pub checked_at: Option>, /// Why this copy cannot install updates itself, when it cannot. pub unsupported: Option, pub automatic: bool, @@ -178,8 +176,8 @@ pub struct Updater { release: Option, manual: Option, staged: Option, - checked_at: Option>, automatic: bool, + channel: Channel, download_failures: u32, job: Option, 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.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) { 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) { + 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) { 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, cx: &mut Context) { - self.checked_at = Some(Local::now()); let Some(services) = &self.services else { return; }; diff --git a/src/workspace/updates.rs b/src/workspace/updates.rs index b4ae07d..db08add 100644 --- a/src/workspace/updates.rs +++ b/src/workspace/updates.rs @@ -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. diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index aab6262..a941638 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -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| { diff --git a/tests/ui.rs b/tests/ui.rs index d829211..f36a7ad 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -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, UpdateError>>, + pair: minisign::KeyPair, + /// What each channel offers, or the error asking for it gives. A + /// channel not listed answers 404. + offers: Mutex)>>, package: Vec, fetches: AtomicUsize, + /// The channels asked for, in order. + channels: Mutex>, downloads: AtomicUsize, } impl UpdateFeed for FakeUpdateFeed { - fn fetch(&self, _: Channel) -> Result, UpdateError> { + fn fetch(&self, channel: Channel) -> Result, 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 { + 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 { +/// `channel`'s manifest offering `version` for this platform, signed by +/// `pair`. +fn signed_manifest( + pair: &minisign::KeyPair, + channel: Channel, + version: &str, + package: &[u8], +) -> Vec { 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, @@ -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, 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, 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) -> Option { + 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);