diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5dcbb75 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,320 @@ +# A release: push a tag `v` matching Cargo.toml's version. +# +# verify the tag, the CHANGELOG section, the update keys +# macos universal app: signed, notarized, stapled; update zip and DMG +# windows per-user Inno Setup installer (unsigned for now) +# linux AppImage +# publish (environment `release`, approved by hand) uploads to R2 behind +# dl.shellrs.com, creates the GitHub release, and only then +# replaces the channel's manifest, which is what clients follow +# +# A tag with a pre-release (v0.3.0-beta.1) goes to the beta channel only. A +# stable release also becomes the beta channel's, unless beta is ahead. +# +# Running it by hand (workflow_dispatch) builds and packages from the +# current commit without publishing, to try the pipeline. +# +# Secrets, see README.md › 发布: +# repository: APPLE_CERTIFICATE_P12 (base64), APPLE_CERTIFICATE_PASSWORD, +# APPLE_SIGNING_IDENTITY, APPLE_TEAM_ID, APPLE_API_KEY_P8 +# (base64), APPLE_API_KEY_ID, APPLE_API_ISSUER +# environment `release`: MINISIGN_SECRET_KEY, MINISIGN_PASSWORD, +# R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, +# R2_BUCKET, CF_ZONE_ID, CF_API_TOKEN +name: release + +on: + push: + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: write + +env: + CARGO_TERM_COLOR: always + +jobs: + verify: + runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} + channel: ${{ steps.version.outputs.channel }} + steps: + - uses: actions/checkout@v4 + - id: version + name: Version and channel + run: | + version=$(grep -m1 '^version = ' Cargo.toml | cut -d'"' -f2) + if [ "$GITHUB_REF_TYPE" = tag ] && [ "${GITHUB_REF_NAME#v}" != "$version" ]; then + echo "::error::tag $GITHUB_REF_NAME does not match Cargo.toml version $version" + exit 1 + fi + case "$version" in *-*) channel=beta ;; *) channel=stable ;; esac + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "channel=$channel" >> "$GITHUB_OUTPUT" + - name: Release notes from CHANGELOG.md + run: | + version=${{ steps.version.outputs.version }} + awk -v heading="## [$version]" ' + index($0, heading) == 1 { found = 1; next } + found && /^## \[/ { exit } + found { print } + ' CHANGELOG.md | sed -e '/./,$!d' > notes.md + if [ ! -s notes.md ]; then + echo "::error::CHANGELOG.md has no section ## [$version]" + exit 1 + fi + cat notes.md + - name: The build knows the update keys + run: | + if grep -q 'pub const TRUSTED_KEYS: &\[&str\] = &\[\];' src/update/build_info.rs; then + echo "::error::src/update/build_info.rs has no TRUSTED_KEYS: a release would reject every update" + exit 1 + fi + - uses: actions/upload-artifact@v4 + with: + name: notes + path: notes.md + + macos: + needs: verify + runs-on: macos-14 + timeout-minutes: 120 + env: + VERSION: ${{ needs.verify.outputs.version }} + SHELLRS_UPDATE_CHANNEL: ${{ needs.verify.outputs.channel }} + SHELLRS_COMMIT: ${{ github.sha }} + SHELLRS_APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + steps: + - uses: actions/checkout@v4 + - run: rustup toolchain install + - run: rustup target add aarch64-apple-darwin x86_64-apple-darwin + - uses: Swatinem/rust-cache@v2 + - name: Build both halves and join them + run: | + export SHELLRS_BUILD_DATE=$(date -u +%Y-%m-%d) + cargo build --release --locked --bin shellrs --target aarch64-apple-darwin + cargo build --release --locked --bin shellrs --target x86_64-apple-darwin + mkdir -p dist build + lipo -create -output build/shellrs \ + target/aarch64-apple-darwin/release/shellrs \ + target/x86_64-apple-darwin/release/shellrs + packaging/macos/bundle.sh build/shellrs "$VERSION" "$GITHUB_RUN_NUMBER" build + - name: Sign + env: + CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE_P12 }} + CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + run: | + keychain="$RUNNER_TEMP/signing.keychain-db" + password=$(uuidgen) + echo "$CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/certificate.p12" + security create-keychain -p "$password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$password" "$keychain" + security import "$RUNNER_TEMP/certificate.p12" -P "$CERTIFICATE_PASSWORD" \ + -A -t cert -f pkcs12 -k "$keychain" + security set-key-partition-list -S apple-tool:,apple: -k "$password" "$keychain" + security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') + codesign --force --timestamp --options runtime --sign "$IDENTITY" build/ShellRS.app + codesign --verify --deep --strict --verbose=2 build/ShellRS.app + - name: Notarize and staple + env: + API_KEY: ${{ secrets.APPLE_API_KEY_P8 }} + API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + run: | + echo "$API_KEY" | base64 --decode > "$RUNNER_TEMP/AuthKey.p8" + notarize() { + xcrun notarytool submit "$1" --key "$RUNNER_TEMP/AuthKey.p8" \ + --key-id "$API_KEY_ID" --issuer "$API_ISSUER" \ + --wait --output-format json > "$RUNNER_TEMP/notary.json" + cat "$RUNNER_TEMP/notary.json" + # --wait returning is not success: the status has to say so. + if [ "$(jq -r .status "$RUNNER_TEMP/notary.json")" != Accepted ]; then + xcrun notarytool log "$(jq -r .id "$RUNNER_TEMP/notary.json")" \ + --key "$RUNNER_TEMP/AuthKey.p8" --key-id "$API_KEY_ID" --issuer "$API_ISSUER" + exit 1 + fi + } + ditto -c -k --keepParent build/ShellRS.app "$RUNNER_TEMP/notarize.zip" + notarize "$RUNNER_TEMP/notarize.zip" + xcrun stapler staple build/ShellRS.app + # The update package: the stapled bundle, zipped the way the + # updater unzips it (ditto keeps the signature intact). + ditto -c -k --keepParent build/ShellRS.app "dist/ShellRS-$VERSION-macos-universal.app.zip" + # The first-install disk image, with the Applications shortcut. + mkdir -p "$RUNNER_TEMP/dmg" + cp -R build/ShellRS.app "$RUNNER_TEMP/dmg/" + ln -s /Applications "$RUNNER_TEMP/dmg/Applications" + dmg="dist/ShellRS-$VERSION-macos-universal.dmg" + hdiutil create -volname ShellRS -srcfolder "$RUNNER_TEMP/dmg" -ov -format UDZO "$dmg" + codesign --force --timestamp --sign "$IDENTITY" "$dmg" + notarize "$dmg" + xcrun stapler staple "$dmg" + spctl -a -vv -t install "$dmg" + - uses: actions/upload-artifact@v4 + with: + name: macos + path: dist/* + + windows: + needs: verify + runs-on: windows-2025 + timeout-minutes: 120 + env: + VERSION: ${{ needs.verify.outputs.version }} + SHELLRS_UPDATE_CHANNEL: ${{ needs.verify.outputs.channel }} + SHELLRS_COMMIT: ${{ github.sha }} + steps: + - uses: actions/checkout@v4 + - run: rustup toolchain install + - uses: Swatinem/rust-cache@v2 + - uses: ilammy/setup-nasm@v1 + - name: Build + shell: bash + run: | + export SHELLRS_BUILD_DATE=$(date -u +%Y-%m-%d) + cargo build --release --locked --bins + - name: Installer + shell: pwsh + run: | + # windows-2025 ships Inno Setup 6. + $iscc = "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe" + if (-not (Test-Path $iscc)) { choco install innosetup -y; } + & $iscc "/DAppVersion=$env:VERSION" "/DBuildDir=$env:GITHUB_WORKSPACE\target\release" ` + "/O$env:GITHUB_WORKSPACE\dist" packaging\windows\shellrs.iss + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - uses: actions/upload-artifact@v4 + with: + name: windows + path: dist/* + + linux: + needs: verify + # Ubuntu 22.04's glibc (2.35) is the oldest the AppImage runs on. + runs-on: ubuntu-22.04 + timeout-minutes: 120 + env: + VERSION: ${{ needs.verify.outputs.version }} + SHELLRS_UPDATE_CHANNEL: ${{ needs.verify.outputs.channel }} + SHELLRS_COMMIT: ${{ github.sha }} + steps: + - uses: actions/checkout@v4 + - name: Build dependencies + run: | + sudo apt-get update + sudo apt-get install -y cmake clang pkg-config libfontconfig-dev \ + libfreetype-dev libxkbcommon-dev libxkbcommon-x11-dev libwayland-dev \ + libx11-xcb-dev libxcb1-dev libvulkan-dev libdbus-1-dev libssl-dev + - run: rustup toolchain install + - uses: Swatinem/rust-cache@v2 + - name: Build + run: | + export SHELLRS_BUILD_DATE=$(date -u +%Y-%m-%d) + cargo build --release --locked --bin shellrs + - name: AppImage + run: | + curl -fsSL -o "$RUNNER_TEMP/appimagetool" \ + https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage + chmod +x "$RUNNER_TEMP/appimagetool" + # No FUSE on the runner: let the tool unpack itself. + export APPIMAGE_EXTRACT_AND_RUN=1 + APPIMAGETOOL="$RUNNER_TEMP/appimagetool" \ + packaging/linux/appimage.sh target/release/shellrs "$VERSION" dist + - uses: actions/upload-artifact@v4 + with: + name: linux + path: dist/* + + publish: + needs: [verify, macos, windows, linux] + if: github.ref_type == 'tag' + runs-on: ubuntu-latest + environment: release + env: + VERSION: ${{ needs.verify.outputs.version }} + CHANNEL: ${{ needs.verify.outputs.channel }} + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + R2: https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com + BUCKET: ${{ secrets.R2_BUCKET }} + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + path: artifacts + - name: Collect + run: | + mkdir -p dist + cp artifacts/macos/* artifacts/windows/* artifacts/linux/* dist/ + cp artifacts/notes/notes.md notes.md + ls -l dist + - name: Signing key + env: + KEY: ${{ secrets.MINISIGN_SECRET_KEY }} + run: | + sudo apt-get update && sudo apt-get install -y minisign + printf '%s\n' "$KEY" > "$RUNNER_TEMP/minisign.key" + echo "MINISIGN_KEY_FILE=$RUNNER_TEMP/minisign.key" >> "$GITHUB_ENV" + - name: Sign the packages + env: + MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }} + run: | + cd dist + for file in ShellRS-*; do + printf '%s\n' "$MINISIGN_PASSWORD" | minisign -S -s "$MINISIGN_KEY_FILE" \ + -m "$file" -t "shellrs $VERSION $file" + done + sha256sum ShellRS-* > SHA256SUMS + - name: Upload the packages + run: | + aws s3 cp dist "s3://$BUCKET/releases/$VERSION/" --recursive \ + --endpoint-url "$R2" --cache-control "public, max-age=31536000, immutable" + - name: GitHub release + run: | + prerelease="" + [ "$CHANNEL" = beta ] && prerelease="--prerelease" + gh release create "v$VERSION" dist/* --title "ShellRS $VERSION" \ + --notes-file notes.md $prerelease + - name: Manifests, last + env: + MINISIGN_PASSWORD: ${{ secrets.MINISIGN_PASSWORD }} + run: | + publish() { + packaging/make-manifest.sh "$VERSION" "$1" notes.md dist "$1.json" + aws s3 cp "$1.json" "s3://$BUCKET/update/v1/$1.json" --endpoint-url "$R2" \ + --content-type application/json --cache-control "public, max-age=300" + echo "https://dl.shellrs.com/update/v1/$1.json" >> purge.txt + } + publish "$CHANNEL" + if [ "$CHANNEL" = stable ]; then + # Beta follows stable unless it is already ahead. + beta=$(curl -fsS https://dl.shellrs.com/update/v1/beta.json | jq -r .manifest | jq -r .version || true) + [ -n "$beta" ] || beta=0.0.0 + if python3 - "$VERSION" "$beta" <<'EOF' + import sys + def key(version): + core, _, pre = version.partition("-") + parts = [int(part) for part in core.split(".")] + # A release sorts after its pre-releases. + return parts, (1,) if not pre else (0, pre) + sys.exit(0 if key(sys.argv[1]) > key(sys.argv[2]) else 1) + EOF + then + publish beta + fi + fi + - name: Purge the manifests from the CDN cache + env: + CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }} + CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }} + run: | + files=$(jq -R . purge.txt | jq -s '{files: .}') + curl -fsS -X POST "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/purge_cache" \ + -H "Authorization: Bearer $CF_API_TOKEN" -H "Content-Type: application/json" \ + --data "$files" diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index c1cf1cd..8f647f6 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -1,6 +1,6 @@ # The Windows build, which the Macs this is developed on cannot compile: -# the whole crate through clippy, and the external CLI's tests, including -# the named pipe between two processes. +# the whole crate through clippy, the external CLI's tests, including the +# named pipe between two processes, and the updater's. name: windows on: @@ -24,4 +24,5 @@ jobs: - uses: ilammy/setup-nasm@v1 - run: cargo clippy --all-targets -- -D warnings - run: "cargo test --lib cli::" + - run: "cargo test --lib update::" - run: cargo test --test cli_bin diff --git a/AGENTS.md b/AGENTS.md index a67619e..348cfb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `explorer/` — 目录与选择快照、`DataTable` 双栏、上传确认 / 冲突对话框、进度和取消 / 恢复展示。`ExplorerAction` 统一路由到工作区;文件扫描与网络操作在后台,目录响应按请求编号丢弃过期结果。 - `sftp/` — 可注入传输与本地目录接口、`RemotePath`、russh-sftp 3.0.0 协议适配、有界并发分块上传、UUID 临时文件、安全替换、原子 JSON 续传记录和三次自动重连。`ssh/connection.rs` 的 `SshConnector` 与终端共享认证、钥匙串服务及 known_hosts 写锁。 - `workspace/` — `workspace_view.rs` 持有 `SessionStore`、`DockArea`、按会话登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`、`status_bar.rs`、`recent_sessions.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。 +- `update/` — 在线升级:`feed.rs` 从 `dl.shellrs.com` 取已签名的清单和安装包(reqwest,走系统代理),`verify.rs` 先验 minisign 签名(trusted comment 绑定通道和版本)再信清单、按大小和 SHA-256 信安装包,`install*.rs` 按安装方式装(macOS 整包交换、Windows 运行 Inno 安装程序、Linux 覆盖 AppImage),`updater.rs` 的 `Updater` 实体管检查、下载和重启,工作线程的事件由 UI 定时轮询。不依赖 `workspace` 和 `settings`;`new_with_services` 里的 `Updater` 没有服务、从不联网,生产路径才 `set_services` + `start`,UI 测试经 `workspace.updater()` 注入假的。详见 `CLAUDE.md` 和 `README.md` 的「下载与更新」「发布」。 - `shared/` — 多个功能共用的展示片段(`ClosableTabTitle`、`HostMark`)。 关键流程与不变量: diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..52c373c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,18 @@ +# 更新日志 + +每个版本一节,标题写成 `## [版本号]`,可以在后面加日期。发布流程(`.github/workflows/release.yml`)把与 tag 同版本的那一节原样作为更新说明:显示在 GitHub Release、ShellRS 的更新对话框和 设置 › 关于 里,用 Markdown 书写,面向使用者。 + +## [0.1.0] + +ShellRS 的第一个公开版本。 + +### 新增 + +- 主机与分组:持久保存,分组可以多层嵌套,支持搜索和拖动整理。 +- 凭据:密码、密钥(本机文件、粘贴或生成新密钥)和 SSH Agent,多台主机可以共用一条凭据。 +- 连接方式:直接连接、SSH 跳板或 HTTP / SOCKS5 代理。 +- 终端:本地终端和多个 SSH 终端,支持查找、清屏和延迟显示。 +- SFTP:对标 WinSCP 的双栏文件浏览器,上传和下载都支持断点续传和传输队列。 +- 端口转发:本地、远程和动态转发,断线后自动重连,可随 ShellRS 一起启动。 +- 外部 CLI:`shellrs` 命令让 AI Agent 用已保存的主机执行命令、传输文件。 +- 在线升级:在后台检查并下载新版本,下好后标题栏右上角提示,重启或退出时安装。 diff --git a/CLAUDE.md b/CLAUDE.md index 2596116..7f70789 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` 的默认私钥,服务器要求密码时直接报错、不询问;密钥之后再要验证码照样问);会话自己不再有私钥文件,要用私钥就建密钥凭据(版本 10 的迁移把原来手动选私钥的会话按「用户 + 私钥文件」各建一条密钥凭据,原来的「自动」归为「密码」);密钥凭据的私钥有三种来源——「本机文件」(只引用路径,不导入内容)、「粘贴」和「生成新密钥」(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`),连接时直接报错,不悄悄改成直连或少跳一级,表单里留着占位就保存会报错;删除不重连已打开的连接。 +已确认的产品决定: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` 的默认私钥,服务器要求密码时直接报错、不询问;密钥之后再要验证码照样问);会话自己不再有私钥文件,要用私钥就建密钥凭据(版本 10 的迁移把原来手动选私钥的会话按「用户 + 私钥文件」各建一条密钥凭据,原来的「自动」归为「密码」);密钥凭据的私钥有三种来源——「本机文件」(只引用路径,不导入内容)、「粘贴」和「生成新密钥」(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(版本、系统、架构),不发任何安装标识。 做 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` 为准。 @@ -24,6 +24,7 @@ cargo test --lib # 只跑单元测试 cargo test --test ui # 只跑 UI 集成测试 cargo test --test ui new_session -- --nocapture # 按名称片段跑单个 UI 测试 cargo test --test cli_bin # 以子进程运行 shellrs-cli 的端到端测试(Windows CI 也跑) +cargo test --lib update:: # 在线升级;装了 minisign 和 jq 时还验证发布脚本签出的清单客户端认 cargo clippy --all-targets -- -D warnings # 必须无警告 cargo fmt --check @@ -40,7 +41,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 单个二进制 crate,带 `src/lib.rs`,这样 `tests/ui.rs` 能驱动生产环境的 `Workspace`。模块按能力划分,将来可拆成独立 crate;功能模块不得依赖 `workspace`(窗口壳),也不得触碰彼此的内部实现。 -- `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `SessionId` / `GroupId` 等 id 的动作,统一由 `id_actions!` 声明);`paths.rs` 解析数据目录(`SHELLRS_DATA_DIR` → `dirs::data_dir()/shellrs`)与数据库路径;`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`;`OS_ICONS` 另外嵌入 `assets/icons/os/*.svg`(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 `Icon::default().path(os.icon_path())` 渲染。 +- `app/` — `actions.rs` 定义全部用户命令(`gpui_kit::actions!` 单元动作 + 携带 `SessionId` / `GroupId` 等 id 的动作,统一由 `id_actions!` 声明);`paths.rs` 解析数据目录(`SHELLRS_DATA_DIR` → `dirs::data_dir()/shellrs`)与数据库路径;`mod.rs` 绑定快捷键、初始化 gpui-kit 并 `set_locale("zh-CN")`;`assets.rs` 用 `icon_assets!` 把额外的 Lucide 图标并入默认图标包——额外图标用 `CatalogIcon::*`,默认包用 `IconName::*`;`OS_ICONS` 另外嵌入 `assets/icons/os/*.svg`(各操作系统的真实 logo,取自 Simple Icons,CC0;Windows 那个是自己画的四格,Simple Icons 已下架),用 `Icon::default().path(os.icon_path())` 渲染;`app_icon.rs` 的 `show_logo_when_unbundled`:不在 .app 包里运行(`cargo run`)时,`main` 用它把 `assets/logo/shellrs.png` 设成 Dock 图标(打包后的图标来自 bundle 的 icns)。Windows 的 exe 图标由 `build.rs`(`winresource`,图标资源 1,gpui 给窗口用的也是它)嵌入 `assets/logo/shellrs.ico`。 - `session/` — `model.rs`(Session / SessionGroup / SessionDraft / GroupDraft / `HostOs` / `PublicId`;`Session.group` 与 `SessionGroup.parent` 都是 `Option`,`Session.os` 是 `Option`,`Session.credential` 是 `Option`,`Session.route` 是连接方式 `Route::{Direct, Jump(Vec>), Proxy(ProxySettings)}`,跳板里的 `None` 是已删除的那一台,`Session.notes` 是对话框最后的「备注」,不进登录快照、改它不重连)、`credential.rs`(凭据的模型:`Credential` / `CredentialDraft` / `CredentialKind`,`validated`、`summary`、`matches_credential_query` 是纯函数;放在 session 里的理由同 `forward.rs`)、`private_key.rs`(粘贴和生成的私钥:`PastedKey::parse` 认出私钥并在不要口令时读出公钥,`GeneratedKey`(`generate` / `public_key_line` / `encode`,生成 RSA 和用口令加密都是阻塞的,只在后台执行器上调用),`read_public_key` 从私钥文件或旁边的 `.pub` 读公钥,`write_key_file` 经临时文件写 0600 的私钥文件;`Debug` 一律打码)、`login.rs`(`SessionLogin` / `LoginMethod` / `LoginRoute`:查好凭据和跳板之后的登录快照,见下面「登录快照在 UI 线程解析」)、`secret_fields.rs`(`SecretFields`:会话对话框和凭据对话框共用的密码、私钥文件和口令字段,含异步预填、`*_loaded` 标志和 `secret_change`;两个对话框各自在自己的 `Form` 里摆放、用各自的 id)、`forward.rs`(端口转发规则的模型:`ForwardRule` / `ForwardDraft` / `ForwardKind` / `ForwardEndpoint`,`validated`、`summary`、`needs_restart`、`matches_forward_query` 都是纯函数;放在 session 里是因为规则和会话同库、随会话级联删除,而 session 不能依赖 `forward`,同 `BookmarkSide`)、`database.rs`(`SessionDatabase`:rusqlite 连接、一份完整的 `SCHEMA` 加 `PRAGMA user_version` 和逐版本的迁移步骤、行与模型的映射)、`store.rs`(`SessionStore` 实体,**内存是唯一事实来源**,写穿到数据库)、`outline.rs`(纯函数:store → `TreeItem`,递归铺开嵌套分组;节点 id 形如 `g:` / `s:`;`group_options` 给表单提供按全路径标注的分组列表)、`session_panel.rs`(左侧 Dock 面板:搜索 + 树 + 右键菜单)、`session_dialog.rs`、`group_dialog.rs`(`GroupForm`、`open_group_dialog`、`confirm_delete_group`)。 - `terminal/` — 本地终端是真的:`transport.rs`(字节流传输的 trait)、`local_pty.rs`(`portable-pty` 实现,跑在专用线程上)、`engine.rs`(驱动 `alacritty_terminal`,解析线程 + 16ms UI 轮询批量刷新)、`terminal_view.rs`(网格渲染、选区、输入编码、内容区右键菜单、查找条)、`search.rs`(查找)、`local_terminal_panel.rs`(⌘T 开的本地标签页)、`model.rs`、`font.rs`(终端字体:全局量 `TerminalFont` 与默认值 / 取值范围、`is_font_installed`、后台用的 `monospace_font_families`、设置页的 `TerminalFontPreview`)。远程会话的传输在 `ssh/`,中间区的标签页是 `terminal_panel.rs`。prompt 协议在 `connection.rs`(`ConnectionPrompt` / `ConnectionPromptKind` / `ConnectionPromptReply` / `ConnectionSecret`),终端、SSH、SFTP 和工作区都直接用这些类型。 - `ssh/` — `exec.rs`(外部 CLI 的 `run_command`:临时、非交互地登录并执行一条命令,见下面「外部 CLI 经由正在运行的应用执行」)、`probe.rs`(探测远端系统的命令与纯解析函数,外加驱动两步探测的 `HostOsProbe` 状态机)、`transport.rs`(远程终端的传输:pty、shell、探测和延迟)、`connection.rs`(`SshConnector`:真实的 russh 登录,终端、SFTP、exec、测试连接和端口转发共用;`connect_forwarding` 是给端口转发的登录,另外返回服务器为远程转发开回来的通道 `ForwardedTcpip` 的接收端;`is_network_error` 区分「网络断了」和「需要人处理」)。`proxy.rs`(HTTP `CONNECT` 和 SOCKS5 的客户端握手,泛型于流,单元测试用 `tokio::io::duplex`)、`route_tests.rs`(进程内的跳板服务器和 HTTP / SOCKS5 代理上的协议测试)。`SshConnector::connect_inner` 先按 `SessionLogin::route` 建底层流(`Tunnel`:直连的 TCP、握手后的代理 TCP,或上一台跳板 `channel_open_direct_tcpip` 的 `ChannelStream`),再 `client::connect_stream`。**跳板连接靠下一跳攥着的通道流活着**:每台跳板登录、开好通道后就丢掉自己的 `Handle`,russh 的会话循环要等所有发送端都没了才退出,所以最终连接一断整条链依次关闭,跳板断线时通道 EOF、最终连接随之断开(`a_host_behind_two_jump_hosts_is_reached_through_them_in_order` 用服务器的 `ended` 计数守着,`a_worker_that_stops_with_a_connection_through_a_jump_host_still_open_stops_cleanly` 守着 runtime 先关的情形)。跳板的问答经 `Asker` / `ConnectionPromptKind::at_jump_host` 写明是哪台跳板(主机密钥框多一行「跳板主机:名称」,密码提示前缀「跳板主机「名称」:」);跳板和代理的失败包一层 `RouteFailure`(文字写明在哪一台、为什么,错误链保留,所以 `is_network_error` 和 `MissingCredential` 照常认),`describe_login_error` 先认它。代理或跳板报告目标连不上时映射成直连同样的 `io::Error` 类型(拒绝、不可达、超时),重试和说明和直连一致。直连的 TCP 错误包成 `russh::Error::IO`,和以前 `client::connect` 一样,解析不了的名字也算网络错误。known_hosts 校验写 `/known_hosts`(**不碰** `~/.ssh/known_hosts`),每次登录先发 `none`(服务器免认证就直接进),再按 `LoginMethod` 走:`Password` 只走密码和键盘交互,`NoPassword` 是 agent → `~/.ssh` 默认私钥、之后没有部分成功就报错(`a_login_without_a_password_refuses_a_server_that_wants_one`),`Key` / `Agent` 是凭据的。登录参数 `SshConnectionConfig` 由 `SessionLogin` 构造;`LoginMethod::Agent`(SSH Agent 凭据)只走 agent,连不上、agent 里没有钥匙、服务器一把都不接受时明确报错,「无密码」遇到这些静默跳过、接着试默认私钥。agent 的位置是 `SshConnector` 上的 `AgentLocation`(默认 `System`:unix 读 `SSH_AUTH_SOCK`,Windows 是 `\\.\pipe\openssh-ssh-agent`,外面包 5 秒超时,因为 russh 在管道忙时会无限重试),协议测试用 `with_agent(AgentLocation::At(..))` 接上 russh 自带的 agent 服务端,不改进程的环境变量。agent 的错误只格式化进文字、不挂进错误链,免得 `is_network_error` 把它当成断网去重试。密码和私钥口令先查 `secrets`,查不到或被服务器拒绝才走 prompt 弹框;被拒绝时**不删**钥匙串条目,只在弹框说明里讲清原因。shell 起来后主循环每 5 秒用 `Handle::send_ping()`(`keepalive@openssh.com`,服务器回成功或失败都算回复)量一次往返,5 秒没回就记为超时,结果以 `TerminalTransportEvent::Latency` 送出;引擎把最新一次存成状态(和 `lifecycle` 一样,重启、退出、失败时清空),`TerminalPanel::title_suffix` 在标签栏右侧显示:< 100 ms 用 `success`,100–200 ms 用 `warning`,> 200 ms 或超时用 `danger`,分档在 `Latency::level`。 @@ -57,7 +58,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `protocol_tests.rs`:进程内 russh 服务器(实现了 direct-tcpip 和 tcpip-forward)加本机回显服务,驱动真实的工作线程。 - `credential/` — 凭据的界面,模型在 `session/credential.rs`。`credential_panel.rs`(侧栏里的凭据列表:搜索、行、挂在容器上的右键菜单、↑↓ 与回车编辑,结构照抄转发列表,行的文字列同样零宽加裁剪)、`credential_dialog.rs`(`CredentialForm` 与 `open_credential_dialog(CredentialDialog::{New, GenerateKey, Edit(id)}, ..)`:类型是 `SegmentedControl::new("credential-kind")`,密钥的来源是 `credential-key-source`(本机文件 / 粘贴 / 生成新密钥),秘密字段用 `session::SecretFields`。生成在选中「生成新密钥」或换算法时就在后台开始(带代次,旧算法的结果丢掉),公钥随名称实时变(名称就是注释);本机文件的公钥在路径变了之后到后台读。提交顺序:先 `SessionStore::save_private_key` 写私钥文件,再 `insert_credential` / `update_credential`,最后 `save_secret`。填了口令的生成密钥要先在后台加密(bcrypt 很慢),这时 `commit` 返回 `false`、对话框留着并显示「正在用口令加密私钥…」,加密完再保存并 `close_dialog`;切换来源会清空口令栏,因为口令属于一把钥匙)。 - `workspace/` — `workspace_view.rs` 持有 `SessionStore`、`DockArea`、按会话登记的面板注册表,以及**全部动作处理器**;`title_bar.rs`(应用名后面是侧栏的切换:`show-sessions` / `show-forwards` / `show-credentials` 三个图标的 `ButtonGroup`,有转发在跑时端口转发那个带数量;`on_click` 按序号 0 / 1 / 2 派发)、`sidebar.rs`(左侧 Dock 唯一的面板 `Sidebar`,见下面「侧栏是一个面板、两个列表」)、`forwards.rs`(`impl Workspace` 的端口转发部分:侧栏切换、转发的动作处理器、`ForwardManagerEvent` 的处理)、`credentials.rs`(`impl Workspace` 的凭据部分:侧栏切换和凭据的新建、编辑、删除)、`status_bar.rs`、`recent_sessions.rs`(中间区没有标签页时显示的「最近连接」开始页,**不是** Dock 面板;行的右键菜单就是会话树里会话的那个菜单,两处都调用 `session::session_menu`,不许分叉)、`dock_skin.rs`(`WorkspaceDockSkin`:包一层 `DockSkin`,中间区为空时用 `deferred` 把开始页画在空的中间区之上;工作区在 `DockEvent::LayoutChanged` 时同步「中间区是否为空」并在刚变空时把焦点移到开始页)。 -- `settings/` — 设置。`model.rs`(`AppSettings`:`InterfaceLanguage` 界面语言、`Appearance` 应用外观、`TerminalFontSettings` 终端字体;两者实现 `Choice`,`key` 是写进文件的值,`label` 是下拉里的文字;`InterfaceLanguage::locale` / `Appearance::theme_mode` 是解析「跟随系统」的纯函数)、`store.rs`(`SettingsStore`,写 `/settings.json`,见下面「设置写穿到 JSON」)、`apply.rs`(`apply`:把设置落到窗口上——`Theme::change` 加 `deepen_list_hover`、`set_locale`、终端字体的全局量,一致时什么也不做)、`settings_panel.rs`(「设置」标签页 `SettingsPanel`)。标签页用 gpui-kit 的 `Settings` 组件分两栏,左边是分类(`CATEGORIES`:外观、终端、外部 CLI,带搜索),右边是所选分类的设置分组。`Settings` 会把没有分组的页整页丢掉(连左栏的条目一起),所以只加有设置项的分类。下拉用 `choice_field` 生成:读写都经过 `SettingsStore`,带默认值,所以页头的「重置全部」可用。`Settings` 的选中分类、搜索词和左栏宽度存在 keyed element state 里,标签页切走不渲染时会丢,切回来回到第一个分类。它是中间区的单例标签(工作区的 `settings_tab: Option<..>`,`CenterTab::Settings`):`OpenSettings`(会话面板底部的「设置」按钮、⌘, / Ctrl+,)已打开时只把它切到前台并聚焦,不另开一个;标签菜单只有关闭一族。 +- `settings/` — 设置。`model.rs`(`AppSettings`:`InterfaceLanguage` 界面语言、`Appearance` 应用外观、`TerminalFontSettings` 终端字体;两者实现 `Choice`,`key` 是写进文件的值,`label` 是下拉里的文字;`InterfaceLanguage::locale` / `Appearance::theme_mode` 是解析「跟随系统」的纯函数)、`store.rs`(`SettingsStore`,写 `/settings.json`,见下面「设置写穿到 JSON」)、`apply.rs`(`apply`:把设置落到窗口上——`Theme::change` 加 `deepen_list_hover`、`set_locale`、终端字体的全局量,一致时什么也不做)、`settings_panel.rs`(「设置」标签页 `SettingsPanel`)。标签页用 gpui-kit 的 `Settings` 组件分两栏,左边是分类(`CATEGORIES`:外观、终端、外部 CLI、关于,带搜索),右边是所选分类的设置分组。`Settings` 会把没有分组的页整页丢掉(连左栏的条目一起),所以只加有设置项的分类。下拉用 `choice_field` 生成:读写都经过 `SettingsStore`,带默认值,所以页头的「重置全部」可用。`Settings` 的选中分类、搜索词和左栏宽度存在 keyed element state 里,标签页切走不渲染时会丢,切回来回到第一个分类。它是中间区的单例标签(工作区的 `settings_tab: Option<..>`,`CenterTab::Settings`):`OpenSettings`(会话面板底部的「设置」按钮、⌘, / Ctrl+,)已打开时只把它切到前台并聚焦,不另开一个;标签菜单只有关闭一族。 - `cli/` — 外部 CLI:给 AI Agent 用的 `shellrs` 命令,以及它在应用里的服务端。 - `mod.rs`:clap 命令(`list` / `exec` / `upload` / `download`)与 `main`。`main.rs` 一开头看参数:去掉 macOS 的 `-psn_*` 之后还有参数就走 CLI、直接 `process::exit`,不初始化 GPUI;没参数才起图形界面。Windows 的发布版是 GUI 子系统(`windows_subsystem = "windows"`,不弹黑框),没有控制台可写,所以这条分派在 Windows 发布版里关掉,命令改由单独的控制台程序 `src/bin/shellrs-cli.rs`(`shellrs-cli.exe`,各平台都构建,`default-run` 仍是 `shellrs`)承担。`main` 在 Windows 控制台上把 stdout / stderr 包进 `ConsoleText`(非法 UTF-8 换成 U+FFFD,被拆开的字符先攒着),命令文本经 `normalize_command` 去掉 BOM、CRLF 换成 LF(PowerShell 往管道里写的就是这样)。 - `protocol.rs`:套接字上的帧:1 字节类型(`J` JSON / `1` stdout / `2` stderr)+ 4 字节大端长度 + 载荷,一条连接一个请求;`ErrorCode` 是给 Agent 判断用的稳定英文码。`Request::Activate` 不属于 `shellrs` 命令,是 ShellRS 被再次打开时发的(见下面「一个数据目录只跑一个 ShellRS」)。 @@ -65,6 +66,14 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - `pipe_windows.rs`:Windows 命名管道的 Win32 调用(`windows-sys`):带 DACL 的实例、`ConnectNamedPipe`、客户端的打开与属主校验。 - `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 拒绝发布)。 + - `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`:更新对话框。 - `shared/` — 多个功能共用的展示片段(`ClosableTabTitle`、`HostMark`、`close_tab_items`,表单里「几选一」的分段控件 `SegmentedControl` / `Segment`,表单对话框的「取消 + 提交」页脚 `commit_footer`、错误行 `form_error`、端口字段的 `parse_port` 和删除确认 `confirm_delete`,以及远程终端和 SFTP 标签共用的「重命名标签」对话框 `open_rename_tab_dialog`,面板实现 `RenamableTab` 即可接入)。 关键流程与不变量: @@ -108,6 +117,9 @@ 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` 时在数据目录下)。 +- **发布走 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` 出去的事件本来就在构造结束后才送达,订阅里弹通知没有这个问题(自动开启的转发启动失败就是这样报的)。 - **一个命令一个处理器。** 按钮、菜单、右键菜单、快捷键都只派发 `app/actions.rs` 里的动作,由 `Workspace` 处理。新增命令加在那里,不要写临时闭包直接改状态。 - **设置写穿到 JSON,由工作区落到窗口上。** 设置是应用级的,不进会话数据库,存在 `settings.json`(`app::settings_path`),整文件先写临时文件再改名替换。`SettingsStore` 和 `SessionStore` 一样内存说了算:`update` 改内存 → 写文件 → `notify`,写失败发 `SettingsStoreEvent::PersistFailed`,工作区弹错误通知;`update_unnotified` 是给单元测试的纯内存一半。文件缺字段用默认值,读不出来就用默认设置并在启动时提示,文件留到下一次改动时覆盖。设置页的下拉只写 store(下拉的 setter 拿不到 `Window`);工作区 `observe_in` store 调 `settings::apply`,另外 `observe_window_appearance` 让「跟随系统」跟着系统切换,构造时在第一帧之前先应用一次。标题栏的主题开关(`ToggleTheme`)也只是把「应用外观」写成当前的反面,不再直接改主题,所以两处永远一致并且会保存。界面语言目前只影响 gpui-kit 自己的文案(`set_locale`),ShellRS 的文案还是中文,所以默认是「简体中文」而不是「跟随系统」;翻译完成后再改默认值。locale 是进程级全局量,UI 测试并行运行,所以 UI 测试不切换语言,只由单元测试覆盖 `locale` 的解析。 @@ -163,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` 注入 `SessionStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-row", id)`、`("session-os", id)` 与开始页的 `("recent-session-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`;开关是 `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 `session-tooltip`(`aria_label` 是 `user@host:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)、`recent-sessions`、`("recent-session", id)`、`recent-new-session`、`session-search`、`new-session`、`new-group`、`theme-toggle`、窗口状态栏右侧的终端尺寸 `status-terminal-size`(`aria_label` 形如「120×36」,即列×行;本地或远程终端在前时才有,SFTP 标签、开始页和设置页没有这一格)、标题栏的侧栏切换 `show-sessions` / `show-forwards`(`checked()` 是当前显示的那个,侧栏隐藏时两个都不是;有转发在跑时后者的 `aria_label` 是「端口转发,N 条运行中」)、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`session-name` / `-host` / `-port` / `-user` / `-password`、「无密码」下面的说明 `session-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_session_dialog_validates_then_inserts`、`renaming_a_group_keeps_the_sessions_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-session`、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-session` / `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("session-auth-source")` 里按序号 `0usize`(密码)/ `1usize`(使用凭据)/ `2usize`(无密码),凭据下拉 `session-credential`(`value` 是「名称(用户 · 类型)」或占位「请选择凭据」)与它下面的说明 `session-credential-summary`;连接方式在 `within("session-route")` 里按序号 `0usize`(直接连接)/ `1usize`(SSH 跳板)/ `2usize`(代理连接),框里的说明 `session-route-note`,跳板的链 `session-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` 助手就是这样;选中后表单清掉选择和搜索词再把剩下的会话放回去),代理的 `session-proxy-kind`(`Select`)/ `session-proxy-host` / `session-proxy-port` / `session-proxy-user` / `session-proxy-password`,最后的备注 `session-notes`(多行 `Textarea` 外面包的 `div`,点它再输入;回车换行,不提交对话框)。`FakeConnectionTester::routes()` 记下每次测试连接的 `LoginRoute` 和表单给的代理密码。凭据测试的夹具 `open_workspace_with_credentials` 关掉动效,远程终端用 `RecordingRemoteProvider` 记下每次连接拿到的 `SessionLogin`(重连也记一次,所以能拿它数重连)。种子会话 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` 注入 `SessionStore::seed()`(或一个临时文件上的真库)。**不要用右键菜单驱动测试**:菜单项的 ElementId 只是序号,而且 `PopupMenu` 实体会让测试以「leaked handles」失败;直接 `window.dispatch_action` 派发菜单会派发的那个动作。测试模块里要显式导入类型,`use gpui_kit::*` 会遮蔽 `#[test]`。被测试查询的元素带稳定的领域 id,自定义 `div` 还要加 `.test_support()`:`("session-row", id)`、`("session-os", id)` 与开始页的 `("recent-session-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 `session-tooltip`(`aria_label` 是 `user@host:port`;悬停后要 `advance_clock` 越过 500 ms 的显示延迟)、`recent-sessions`、`("recent-session", id)`、`recent-new-session`、`session-search`、`new-session`、`new-group`、`theme-toggle`、窗口状态栏右侧的终端尺寸 `status-terminal-size`(`aria_label` 形如「120×36」,即列×行;本地或远程终端在前时才有,SFTP 标签、开始页和设置页没有这一格)、标题栏的侧栏切换 `show-sessions` / `show-forwards`(`checked()` 是当前显示的那个,侧栏隐藏时两个都不是;有转发在跑时后者的 `aria_label` 是「端口转发,N 条运行中」)、`status-connection`、`commit`、`ok`(确认对话框)、`form-error`、`session-name` / `-host` / `-port` / `-user` / `-password`、「无密码」下面的说明 `session-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_session_dialog_validates_then_inserts`、`renaming_a_group_keeps_the_sessions_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-session`、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-session` / `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("session-auth-source")` 里按序号 `0usize`(密码)/ `1usize`(使用凭据)/ `2usize`(无密码),凭据下拉 `session-credential`(`value` 是「名称(用户 · 类型)」或占位「请选择凭据」)与它下面的说明 `session-credential-summary`;连接方式在 `within("session-route")` 里按序号 `0usize`(直接连接)/ `1usize`(SSH 跳板)/ `2usize`(代理连接),框里的说明 `session-route-note`,跳板的链 `session-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` 助手就是这样;选中后表单清掉选择和搜索词再把剩下的会话放回去),代理的 `session-proxy-kind`(`Select`)/ `session-proxy-host` / `session-proxy-port` / `session-proxy-user` / `session-proxy-password`,最后的备注 `session-notes`(多行 `Textarea` 外面包的 `div`,点它再输入;回车换行,不提交对话框)。`FakeConnectionTester::routes()` 记下每次测试连接的 `LoginRoute` 和表单给的代理密码。凭据测试的夹具 `open_workspace_with_credentials` 关掉动效,远程终端用 `RecordingRemoteProvider` 记下每次连接拿到的 `SessionLogin`(重连也记一次,所以能拿它数重连)。在线升级相关的有:「关于」页的 `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` 初始为已连接并已打开终端标签。 diff --git a/Cargo.lock b/Cargo.lock index 704821d..b3ff51f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1304,6 +1304,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "compression-codecs" version = "0.4.41" @@ -1671,6 +1681,12 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "ct-codecs" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd9f3db6f2cfef61c10613071955154ffdc9e515daebff26de4b54e35038fdd" + [[package]] name = "ctor" version = "1.0.13" @@ -3288,7 +3304,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "sync_wrapper", - "system-configuration", + "system-configuration 0.6.1", "tokio", "tokio-rustls", "tokio-socks", @@ -3794,18 +3810,23 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64 0.22.1", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", "socket2", + "system-configuration 0.7.0", "tokio", "tower-service", "tracing", + "windows-registry 0.6.1", ] [[package]] @@ -4163,6 +4184,36 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -4694,6 +4745,24 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "minisign" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26541387415a1e829df5d532aad019fb11bc723e2b5bc99edefa4cf5bfad0de7" +dependencies = [ + "ct-codecs", + "getrandom 0.2.17", + "rpassword", + "scrypt 0.11.0", +] + +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -5840,7 +5909,7 @@ dependencies = [ "der", "pbkdf2 0.13.0", "rand_core 0.10.1", - "scrypt", + "scrypt 0.12.0", "sha2 0.11.0", "spki", ] @@ -6227,6 +6296,7 @@ version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.4.3", "lru-slab", @@ -6595,6 +6665,46 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "resvg" version = "0.45.1" @@ -6686,6 +6796,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + [[package]] name = "rsa" version = "0.10.0-rc.18" @@ -6715,6 +6836,16 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "rtoolbox" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "rusqlite" version = "0.40.2" @@ -6783,8 +6914,8 @@ dependencies = [ "rsa", "russh-cryptovec", "russh-util", - "salsa20", - "scrypt", + "salsa20 0.11.0", + "scrypt 0.12.0", "sec1", "sha1", "sha2 0.11.0", @@ -6986,6 +7117,7 @@ version = "0.23.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" dependencies = [ + "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", @@ -7025,12 +7157,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + [[package]] name = "rustls-webpki" version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted 0.9.0", @@ -7078,6 +7238,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "salsa20" version = "0.11.0" @@ -7167,6 +7336,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2 0.12.2", + "salsa20 0.10.2", + "sha2 0.10.9", +] + [[package]] name = "scrypt" version = "0.12.0" @@ -7175,7 +7355,7 @@ checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0" dependencies = [ "cfg-if", "pbkdf2 0.13.0", - "salsa20", + "salsa20 0.11.0", "sha2 0.11.0", ] @@ -7518,11 +7698,19 @@ dependencies = [ "futures", "gpui-kit", "keyring", + "libc", + "minisign", + "minisign-verify", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "portable-pty", "rand 0.9.5", + "reqwest", "rusqlite", "russh", "russh-sftp", + "semver", "serde", "serde_json", "sha2 0.10.9", @@ -7534,6 +7722,7 @@ dependencies = [ "uuid", "windows-registry 0.6.1", "windows-sys 0.61.2", + "winresource", "zeroize", ] @@ -7585,6 +7774,16 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simd_helpers" version = "0.1.0" @@ -8084,6 +8283,17 @@ dependencies = [ "system-configuration-sys", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + [[package]] name = "system-configuration-sys" version = "0.6.0" @@ -8475,6 +8685,24 @@ dependencies = [ "tower-service", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -9168,6 +9396,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -10033,6 +10270,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "winresource" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +dependencies = [ + "toml 1.1.5+spec-1.1.0", + "version_check", +] + [[package]] name = "wio" version = "0.2.2" diff --git a/Cargo.toml b/Cargo.toml index 22658dc..a8d6517 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,28 @@ unicode-width = "0.2" zeroize = "1" keyring = "4" trash = "5" +# Online update: the signed manifest, its versions and the downloads. The +# default features keep rustls with the OS trust store and the system proxy +# (a GUI app started from Finder or the Start menu has no HTTPS_PROXY). +minisign-verify = "0.2" +reqwest = { version = "0.13", features = ["blocking"] } +semver = { version = "1", features = ["serde"] } + +[target.'cfg(unix)'.dependencies] +# `access(2)`: whether the folder ShellRS runs from can take its update. +libc = "0.2" + +# The Dock icon when running outside an app bundle (`cargo run`). GPUI +# already builds these crates. +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-app-kit = { version = "0.3", default-features = false, features = [ + "std", + "NSApplication", + "NSImage", + "NSResponder", +] } +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSData"] } [target.'cfg(windows)'.dependencies] windows-registry = "0.6" @@ -56,10 +78,22 @@ windows-sys = { version = "0.61", features = [ "Win32_UI_WindowsAndMessaging", ] } +# The icon in the Windows executables (build.rs). +[target.'cfg(windows)'.build-dependencies] +winresource = "0.1" + [dev-dependencies] tokio = { version = "1", features = ["process", "test-util"] } gpui-kit = { version = "0.6.1", features = ["test-support"] } tempfile = "3" +minisign = "0.7" + +# What the release workflow ships: smaller and faster than the default, at +# the cost of a longer build. +[profile.release] +lto = "thin" +codegen-units = 1 +strip = true # Optimize the rendering stack in debug builds (per gpui-kit's installation # guide); shellrs itself stays unoptimized and debuggable. diff --git a/README.md b/README.md index fb3151e..80d5820 100644 --- a/README.md +++ b/README.md @@ -83,12 +83,13 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已 ## 设置 -侧栏最下方的「设置」按钮(或 ⌘,,其他平台 Ctrl+,)在中间区打开「设置」标签页;已经打开时切换到该标签,不会重复打开。设置页分两栏:左边是分类(外观、终端、外部 CLI),上方可搜索,栏宽可拖动;右边是所选分类的设置。设置改动立即生效并保存在数据目录的 `settings.json` 里。 +侧栏最下方的「设置」按钮(或 ⌘,,其他平台 Ctrl+,)在中间区打开「设置」标签页;已经打开时切换到该标签,不会重复打开。设置页分两栏:左边是分类(外观、终端、外部 CLI、关于),上方可搜索,栏宽可拖动;右边是所选分类的设置。设置改动立即生效并保存在数据目录的 `settings.json` 里。 - **外观 → 常规 → 界面语言**:跟随系统、简体中文、English,默认简体中文。英文界面尚在翻译中,目前只有 GPUI Kit 组件自带的文字(如搜索框提示)会切换。 - **外观 → 常规 → 应用外观**:深色、浅色、跟随系统,默认跟随系统;选「跟随系统」时随系统的深浅色切换。标题栏的主题按钮也会改写这一项。 - **终端 → 字体配置**:字体(只列出本机安装的等宽字体,默认是系统的等宽字体,macOS 上为 Menlo)、字号(8–32 像素,默认 13)、行高(字号的 1.0–2.0 倍,默认 1.54,即原来的 20 像素行高)。改动立即作用于所有已打开的终端。下方的预览按终端的排法显示字母、数字、易混字符、符号、中文、制表符和一行命令。设置文件里的字体在本机没有安装时,使用默认字体。 - **外部 CLI**:见下一节。 +- **关于**:版本、构建和平台,以及在线升级,见「下载与更新」。 ## 外部 CLI @@ -262,6 +263,45 @@ shellrs download <远程路径> <本地路径> **尚未实现:目录同步、前台传输进度对话框、队列排序、过滤、查找文件、目录树和 Dock 布局持久化。** +## 下载与更新 + +安装包在 [shellrs.com/download](https://shellrs.com/download): + +| 系统 | 首次安装 | 自动更新 | +|---|---|---| +| macOS(Apple Silicon 和 Intel 共用一个包) | DMG,把 ShellRS 拖进「应用程序」 | 支持 | +| Windows x64 | 安装程序,按当前用户安装到 `%LOCALAPPDATA%\Programs\ShellRS`,不需要管理员 | 支持 | +| Linux x64 | AppImage | 支持 | + +ShellRS 启动 30 秒后检查一次有没有新版本,之后每 6 小时检查一次。有新版本就在后台下载并校验,完成后标题栏右上角出现更新图标。点开可以看更新内容,点「重启更新」就换成新版本并重新打开;不点也没关系,退出 ShellRS 时会装好,下次打开就是新版本。重启会断开已打开的连接、停止传输(续传进度保留)和端口转发,对话框里会写明具体有哪些。 + +「设置 › 关于」可以随时检查更新,看下载进度和更新内容,也可以关掉「自动检查并下载更新」。关掉之后只在手动检查时才联网,发现新版本时由你决定是否下载。 + +- **校验**:更新清单由 ShellRS 的发布密钥签名,安装包按清单里的大小和 SHA-256 核对,任何一步对不上都不会安装。macOS 上还会核对新版本的开发者签名。 +- **隐私**:检查更新只发送 ShellRS 的版本号、操作系统和 CPU 架构,不发送任何能识别这台电脑或用户的信息。 +- **不能自动更新的情况**:从 DMG 里直接运行、没有放进「应用程序」文件夹、对安装位置没有写权限、Linux 上不是用 AppImage 运行,或者是自己编译的开发构建。这时「关于」页会说明原因,有新版本时给出「前往下载页」。 +- **退回旧版本**:数据库在升级到新结构之前会备份成 `shellrs.db.v<旧版本>.bak`。旧版本打开新结构的数据库时会拒绝打开,以免把新版本保存的数据弄乱;需要退回时,把备份改名回 `shellrs.db`。 + +## 发布 + +在 `CHANGELOG.md` 里写好 `## [x.y.z]` 一节,把 `Cargo.toml` 的版本改成 `x.y.z`,提交后推送 tag `vx.y.z`。`.github/workflows/release.yml` 依次: + +1. 核对 tag 与 `Cargo.toml` 一致,截出更新说明,确认代码里有发布公钥; +2. 构建并打包:macOS 合成 universal,签名、公证、staple,产出升级用的 `.app.zip` 和首次安装用的 `.dmg`;Windows 用 Inno Setup 打安装程序;Linux 打 AppImage; +3. 在 `release` 环境里人工批准后:给每个包签名,上传到 R2(`dl.shellrs.com/releases/<版本>/`),建 GitHub Release,**最后**才替换更新清单 `dl.shellrs.com/update/v1/<通道>.json` 并清 CDN 缓存。 + +版本号带 `-` 的(如 `0.3.0-beta.1`)只发到 beta 通道;正式版同时成为 beta 通道的最新版。在 Actions 里手动运行这个 workflow 只构建打包、不发布,用来演练。客户端只接受比自己新的版本,所以发错的版本撤不回来:把清单改回上一版能阻止更多人升级,修复要发新的补丁版。 + +一次性准备: + +- **更新签名密钥**:`minisign -G -p shellrs-update.pub -s shellrs-update.key` 生成两对(一对日常用,一对离线保存备用),把两个公钥(`.pub` 文件的第二行)填进 `src/update/build_info.rs` 的 `TRUSTED_KEYS`。私钥和口令只放进 GitHub 的 `release` 环境,另外离线备份;丢了私钥,已发出去的 ShellRS 就再也收不到更新。换钥匙时先发一版带上新公钥,再换签名用的私钥。 +- **Cloudflare**:shellrs.com 的 DNS 托管在 Cloudflare;建 R2 bucket,绑定自定义域 `dl.shellrs.com`,关闭 `r2.dev` 地址;Cache Rules 让 `/releases/*` 和 `/update/*` 按源站的 Cache-Control 缓存(默认不缓存 `.json`、`.AppImage`);一个只能写这个 bucket 的 R2 API Token,一个只能清这个 zone 缓存的 API Token。 +- **Apple**:Developer ID Application 证书(导出 .p12)和 notarytool 用的 App Store Connect API Key。`packaging/macos/Info.plist.in` 里的 bundle id `com.shellrs.ShellRS` 和签名的 Team ID 一经发布就不能再改:钥匙串按它们授权,改了之后每条保存的密码都要重新允许。 +- **图标**:`assets/logo/` 里的 `shellrs.icns`(macOS)、`shellrs.png`(1024×1024,Linux,macOS 没有 icns 时也由它生成)和 `shellrs.ico`(Windows 安装程序)。 +- **GitHub secrets**:仓库级的 `APPLE_CERTIFICATE_P12`(base64)、`APPLE_CERTIFICATE_PASSWORD`、`APPLE_SIGNING_IDENTITY`、`APPLE_TEAM_ID`、`APPLE_API_KEY_P8`(base64)、`APPLE_API_KEY_ID`、`APPLE_API_ISSUER`;`release` 环境(设为需要审批)里的 `MINISIGN_SECRET_KEY`、`MINISIGN_PASSWORD`、`R2_ACCOUNT_ID`、`R2_ACCESS_KEY_ID`、`R2_SECRET_ACCESS_KEY`、`R2_BUCKET`、`CF_ZONE_ID`、`CF_API_TOKEN`。 + +清单格式见 `packaging/manifest.example.json`,由 `packaging/make-manifest.sh` 生成:一个文件里是清单原文和它的 minisign 签名,签名的 trusted comment 写明通道和版本。格式只增加字段、不改已有的;已发出去的 ShellRS 一直按 `/update/v1/` 这个地址检查。 + ## 运行和验证 工具链固定 Rust 1.98.1,`russh-sftp` 固定 3.0.0;保持仓库锁定的 GPUI 与 russh 版本。 @@ -279,6 +319,8 @@ cargo fmt --check `src/forward/protocol_tests.rs` 用进程内的 SSH 服务器和本机的回显服务测试真实的转发工作线程:三种转发的往返、目标不可达、服务器禁止转发、端口被占用、空闲断线后重连、重试耗尽和停止后的清理,同样只监听 `127.0.0.1` 的随机端口。 -`tests/ui.rs` 驱动真实 `Workspace`,通过 `Workspace::new_with_services` 注入 SFTP、本地目录、终端和端口转发服务,不连接用户服务器或真实钥匙串。Finder 的系统原生拖放仍需在真实 macOS 窗口中补充人工验收;无头测试覆盖原生文件拖入事件和面板内部拖放。 +`tests/ui.rs` 驱动真实 `Workspace`,通过 `Workspace::new_with_services` 注入 SFTP、本地目录、终端和端口转发服务,不连接用户服务器或真实钥匙串;在线升级的测试给 `workspace.updater()` 注入假的更新服务器和安装器,不联网、不改任何安装。Finder 的系统原生拖放仍需在真实 macOS 窗口中补充人工验收;无头测试覆盖原生文件拖入事件和面板内部拖放。 + +开发构建(`cargo run`)不检查更新。要在本机走一遍更新流程,用 debug 构建加环境变量 `SHELLRS_UPDATE_CHANNEL=beta` 编译,运行时用 `SHELLRS_UPDATE_URL=http://127.0.0.1:8000/{channel}.json` 指向本地的静态服务器(`python3 -m http.server`),清单用 `packaging/make-manifest.sh` 和自己生成的测试密钥签名,测试公钥放在环境变量 `SHELLRS_UPDATE_PUBLIC_KEY` 里。release 构建不认这两个环境变量。 上传交互参考 [WinSCP 上传流程](https://winscp.net/eng/docs/task_upload),协议适配参考 [russh-sftp 请求接口](https://docs.rs/russh-sftp/3.0.0/russh_sftp/client/struct.RawSftpSession.html) 与 [OpenSSH 扩展规范](https://raw.githubusercontent.com/openssh/openssh-portable/master/PROTOCOL)。 diff --git a/assets/logo/design-prompt.txt b/assets/logo/design-prompt.txt new file mode 100644 index 0000000..d2ec7bc --- /dev/null +++ b/assets/logo/design-prompt.txt @@ -0,0 +1,22 @@ +ShellRS 最终版设计说明 + +以 shellrs.svg 为最终设计源文件;最终版本经过矢量路径重绘,不应使用早期生成图替代。 + +设计要求: +- 深蓝圆角方形底板,外部透明,整体为纯色扁平设计。 +- 内容按「>|RS」从左到右排列,大小依次递减。 +- 象牙白「>」是主体,上下两笔严格镜像对称,笔画等粗,端点和连接处使用圆角。 +- 青绿色竖直光标、较小的象牙白 RS 为辅助元素。 +- RS 按用户确认的原稿轮廓描摹为矢量路径,不替换成现成字体。 +- 三个元素底部对齐,整组内容在底板内水平、垂直居中。 +- 不使用渐变、阴影、高光、纹理或立体效果。 + +最终规格: +- 画布:1024 × 1024。 +- 底板:x=64、y=64、宽高=896、圆角半径=176。 +- 底板色:#082F63;主体与字母色:#FFF9EB;光标色:#00D9C5。 +- 内容顶部 y=228、底部 y=796;底板内上下留白各 164。 +- SVG 字母已转为路径,无外部字体依赖。 +- PNG:1024 × 1024,保留透明通道。 +- ICNS:多尺寸,包含 1024 × 1024。 +- ICO:16、24、32、48、64、128、256 像素。 diff --git a/assets/logo/shellrs.icns b/assets/logo/shellrs.icns new file mode 100644 index 0000000..b51cf32 Binary files /dev/null and b/assets/logo/shellrs.icns differ diff --git a/assets/logo/shellrs.ico b/assets/logo/shellrs.ico new file mode 100644 index 0000000..be5959f Binary files /dev/null and b/assets/logo/shellrs.ico differ diff --git a/assets/logo/shellrs.png b/assets/logo/shellrs.png new file mode 100644 index 0000000..fc275f6 Binary files /dev/null and b/assets/logo/shellrs.png differ diff --git a/assets/logo/shellrs.svg b/assets/logo/shellrs.svg new file mode 100644 index 0000000..94c6721 --- /dev/null +++ b/assets/logo/shellrs.svg @@ -0,0 +1,10 @@ + +ShellRS — centered lettering preview +Original approved RS contours traced to vectors. Foreground group centered within tile; common bottom at y=796. + + + + + + + diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..e12f4e5 --- /dev/null +++ b/build.rs @@ -0,0 +1,19 @@ +//! On Windows, the logo is the executables' icon: Explorer and the taskbar +//! show it, and GPUI gives its windows icon resource 1, which `set_icon` +//! fills. Other platforms take the icon from their package (`packaging/`). + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=assets/logo/shellrs.ico"); + #[cfg(windows)] + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { + let mut resource = winresource::WindowsResource::new(); + resource + .set_icon("assets/logo/shellrs.ico") + .set("ProductName", "ShellRS") + .set("FileDescription", "ShellRS"); + if let Err(error) = resource.compile() { + panic!("cannot embed the icon: {error}"); + } + } +} diff --git a/packaging/linux/appimage.sh b/packaging/linux/appimage.sh new file mode 100755 index 0000000..ad30b33 --- /dev/null +++ b/packaging/linux/appimage.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Put a built `shellrs` binary into an AppImage. +# +# packaging/linux/appimage.sh +# +# Needs appimagetool on the PATH (or APPIMAGETOOL), which uses the static +# type2 runtime: the AppImage runs without libfuse2, which Ubuntu 22.04 and +# later no longer install. The graphics stack (Vulkan, Mesa, Wayland, X) is +# the host's and is not bundled. The icon is assets/logo/shellrs.png. +set -euo pipefail + +binary=$1 +version=$2 +out=$3 + +here=$(cd "$(dirname "$0")" && pwd) +root=$(cd "$here/../.." && pwd) +tool=${APPIMAGETOOL:-appimagetool} + +work=$(mktemp -d) +appdir="$work/ShellRS.AppDir" +mkdir -p "$appdir/usr/bin" +cp "$binary" "$appdir/usr/bin/shellrs" +chmod 755 "$appdir/usr/bin/shellrs" +cp "$here/shellrs.desktop" "$appdir/shellrs.desktop" +icon="$root/assets/logo/shellrs.png" +if [ ! -f "$icon" ]; then + echo "error: $icon is missing; an AppImage needs an icon" >&2 + exit 1 +fi +cp "$icon" "$appdir/shellrs.png" +# The executable itself is the entry point; the restart after an update +# starts $APPIMAGE again, so nothing here needs to survive it. +ln -s usr/bin/shellrs "$appdir/AppRun" + +mkdir -p "$out" +target="$out/ShellRS-$version-linux-x86_64.AppImage" +ARCH=x86_64 "$tool" --no-appstream "$appdir" "$target" +rm -rf "$work" +echo "$target" diff --git a/packaging/linux/shellrs.desktop b/packaging/linux/shellrs.desktop new file mode 100644 index 0000000..b3507ef --- /dev/null +++ b/packaging/linux/shellrs.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=ShellRS +Comment=SSH 主机管理:终端、SFTP 与端口转发 +Exec=shellrs +Icon=shellrs +Terminal=false +Categories=Network;RemoteAccess;Development; +StartupWMClass=shellrs diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in new file mode 100644 index 0000000..b573ed6 --- /dev/null +++ b/packaging/macos/Info.plist.in @@ -0,0 +1,42 @@ + + + + + + CFBundleDevelopmentRegion + zh_CN + CFBundleDisplayName + ShellRS + CFBundleExecutable + shellrs + CFBundleIconFile + ShellRS + CFBundleIdentifier + com.shellrs.ShellRS + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ShellRS + CFBundlePackageType + APPL + CFBundleShortVersionString + @VERSION@ + CFBundleVersion + @BUILD@ + LSApplicationCategoryType + public.app-category.developer-tools + LSMinimumSystemVersion + 11.0 + NSHighResolutionCapable + + NSLocalNetworkUsageDescription + ShellRS 需要访问本地网络,才能连接局域网中的主机。 + NSHumanReadableCopyright + © ShellRS + + diff --git a/packaging/macos/bundle.sh b/packaging/macos/bundle.sh new file mode 100755 index 0000000..3d8e91e --- /dev/null +++ b/packaging/macos/bundle.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Put a built `shellrs` binary into ShellRS.app. +# +# packaging/macos/bundle.sh +# +# The icon is assets/logo/shellrs.icns, or made from assets/logo/shellrs.png +# (1024×1024) when only that is there. Signing, notarizing and zipping are +# the release workflow's steps. +set -euo pipefail + +binary=$1 +version=$2 +build=$3 +out=$4 + +here=$(cd "$(dirname "$0")" && pwd) +root=$(cd "$here/../.." && pwd) +app="$out/ShellRS.app" + +rm -rf "$app" +mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" +cp "$binary" "$app/Contents/MacOS/shellrs" +chmod 755 "$app/Contents/MacOS/shellrs" +sed -e "s/@VERSION@/$version/g" -e "s/@BUILD@/$build/g" \ + "$here/Info.plist.in" > "$app/Contents/Info.plist" +plutil -lint "$app/Contents/Info.plist" >/dev/null + +icns="$root/assets/logo/shellrs.icns" +icon="$root/assets/logo/shellrs.png" +if [ -f "$icns" ]; then + cp "$icns" "$app/Contents/Resources/ShellRS.icns" +elif [ -f "$icon" ]; then + work=$(mktemp -d) + iconset="$work/ShellRS.iconset" + mkdir -p "$iconset" + for size in 16 32 128 256 512; do + sips -z "$size" "$size" "$icon" --out "$iconset/icon_${size}x${size}.png" >/dev/null + double=$((size * 2)) + sips -z "$double" "$double" "$icon" --out "$iconset/icon_${size}x${size}@2x.png" >/dev/null + done + iconutil -c icns "$iconset" -o "$app/Contents/Resources/ShellRS.icns" + rm -rf "$work" +else + echo "warning: no icon in assets/logo; the bundle has none" >&2 +fi + +echo "$app" diff --git a/packaging/make-manifest.sh b/packaging/make-manifest.sh new file mode 100755 index 0000000..cbc84ff --- /dev/null +++ b/packaging/make-manifest.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Write the signed update manifest of one release for one channel. +# +# packaging/make-manifest.sh +# +# holds the release's packages under their published names. +# MINISIGN_KEY_FILE is the secret key; MINISIGN_PASSWORD its password, if it +# has one. SHELLRS_MIRROR_BASE, when set (e.g. the GitHub release download +# URL of a public repository), adds a second address for every package. +# +# The output is the envelope src/update/manifest.rs reads: the manifest's +# exact text and its minisign signature, whose trusted comment +# `shellrs-manifest ` the client checks. The format only +# ever gains fields; packaging/manifest.example.json shows it. +set -euo pipefail + +version=$1 +channel=$2 +notes=$3 +dist=$4 +out=$5 + +base="https://dl.shellrs.com/releases/$version" +mirror=${SHELLRS_MIRROR_BASE:-} + +mac="ShellRS-$version-macos-universal.app.zip" +dmg="ShellRS-$version-macos-universal.dmg" +win="ShellRS-$version-windows-x86_64-setup.exe" +linux="ShellRS-$version-linux-x86_64.AppImage" + +sha256() { + if command -v sha256sum >/dev/null; then + sha256sum "$1" | cut -d' ' -f1 + else + shasum -a 256 "$1" | cut -d' ' -f1 + fi +} + +# The asset entry of one package: its addresses, size and SHA-256. +asset() { + local file="$dist/$1" + local size + size=$(wc -c <"$file" | tr -d ' ') + jq -n \ + --arg url "$base/$1" \ + --arg mirror "${mirror:+$mirror/$1}" \ + --argjson size "$size" \ + --arg sha256 "$(sha256 "$file")" \ + '{urls: ([$url] + (if $mirror == "" then [] else [$mirror] end)), size: $size, sha256: $sha256}' +} + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +jq -n -j \ + --arg version "$version" \ + --arg channel "$channel" \ + --arg published "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --rawfile notes "$notes" \ + --arg notes_url "https://shellrs.com/changelog#$version" \ + --argjson mac "$(asset "$mac")" \ + --argjson win "$(asset "$win")" \ + --argjson linux "$(asset "$linux")" \ + --arg dmg "$base/$dmg" \ + --arg win_url "$base/$win" \ + --arg linux_url "$base/$linux" \ + '{ + schema: 1, + channel: $channel, + version: $version, + published_at: $published, + minimum_version: null, + rollout: 1.0, + notes: $notes, + notes_url: $notes_url, + assets: { + "macos-aarch64": $mac, + "macos-x86_64": $mac, + "windows-x86_64": $win, + "linux-x86_64": $linux + }, + installers: { + "macos-aarch64": $dmg, + "macos-x86_64": $dmg, + "windows-x86_64": $win_url, + "linux-x86_64": $linux_url + } + }' >"$work/manifest.json" + +printf '%s\n' "${MINISIGN_PASSWORD:-}" | minisign -S \ + -s "$MINISIGN_KEY_FILE" \ + -m "$work/manifest.json" \ + -x "$work/manifest.json.minisig" \ + -t "shellrs-manifest $channel $version" + +jq -n \ + --rawfile manifest "$work/manifest.json" \ + --rawfile signature "$work/manifest.json.minisig" \ + '{manifest: $manifest, signature: $signature}' >"$out" +echo "$out" diff --git a/packaging/manifest.example.json b/packaging/manifest.example.json new file mode 100644 index 0000000..88d2329 --- /dev/null +++ b/packaging/manifest.example.json @@ -0,0 +1,38 @@ +{ + "schema": 1, + "channel": "stable", + "version": "0.2.0", + "published_at": "2026-10-20T08:00:00Z", + "minimum_version": null, + "rollout": 1.0, + "notes": "### 新增\n\n- 在线升级:设置 › 关于 可以检查更新,新版本在后台下载好后,标题栏右上角会提示。\n", + "notes_url": "https://shellrs.com/changelog#0.2.0", + "assets": { + "macos-aarch64": { + "urls": ["https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-universal.app.zip"], + "size": 41234567, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "macos-x86_64": { + "urls": ["https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-universal.app.zip"], + "size": 41234567, + "sha256": "0000000000000000000000000000000000000000000000000000000000000000" + }, + "windows-x86_64": { + "urls": ["https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-windows-x86_64-setup.exe"], + "size": 18234567, + "sha256": "1111111111111111111111111111111111111111111111111111111111111111" + }, + "linux-x86_64": { + "urls": ["https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-linux-x86_64.AppImage"], + "size": 28234567, + "sha256": "2222222222222222222222222222222222222222222222222222222222222222" + } + }, + "installers": { + "macos-aarch64": "https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-universal.dmg", + "macos-x86_64": "https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-universal.dmg", + "windows-x86_64": "https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-windows-x86_64-setup.exe", + "linux-x86_64": "https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-linux-x86_64.AppImage" + } +} diff --git a/packaging/windows/shellrs.iss b/packaging/windows/shellrs.iss new file mode 100644 index 0000000..97713f0 --- /dev/null +++ b/packaging/windows/shellrs.iss @@ -0,0 +1,73 @@ +; The Windows setup program: installs ShellRS for the current user, with no +; administrator rights, under %LOCALAPPDATA%\Programs\ShellRS. +; +; iscc /DAppVersion=0.2.0 /DBuildDir=..\..\target\release /O..\..\dist shellrs.iss +; +; The same program updates an installed copy: ShellRS downloads the next +; version's setup and runs it with /VERYSILENT /SUPPRESSMSGBOXES /NORESTART +; /SP- once it has exited, then starts itself again. AppId must never +; change, or the update installs a second copy beside the first. + +#ifndef AppVersion + #error Pass /DAppVersion= +#endif +#ifndef BuildDir + #define BuildDir "..\..\target\release" +#endif +#define IconFile AddBackslash(SourcePath) + "..\..\assets\logo\shellrs.ico" + +[Setup] +AppId={{45D9E9F5-84BB-4B09-ABE8-1FFE107ACF96} +AppName=ShellRS +AppVersion={#AppVersion} +AppVerName=ShellRS {#AppVersion} +AppPublisher=ShellRS +AppPublisherURL=https://shellrs.com +AppSupportURL=https://shellrs.com +AppUpdatesURL=https://shellrs.com/download +DefaultDirName={autopf}\ShellRS +DisableDirPage=auto +DisableProgramGroupPage=yes +PrivilegesRequired=lowest +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +OutputBaseFilename=ShellRS-{#AppVersion}-windows-x86_64-setup +Compression=lzma2/max +SolidCompression=yes +WizardStyle=modern +UninstallDisplayIcon={app}\shellrs.exe +UninstallDisplayName=ShellRS +; A running ShellRS is closed through the Restart Manager rather than +; making the setup fail on files in use. +CloseApplications=yes +#if FileExists(IconFile) +SetupIconFile={#IconFile} +#endif + +[Languages] +#if FileExists(AddBackslash(CompilerPath) + "Languages\ChineseSimplified.isl") +Name: "chinesesimplified"; MessagesFile: "compiler:Languages\ChineseSimplified.isl" +#else +Name: "english"; MessagesFile: "compiler:Default.isl" +#endif + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[Files] +Source: "{#BuildDir}\shellrs.exe"; DestDir: "{app}"; Flags: ignoreversion +; The `shellrs` command: 设置 › 外部 CLI copies it onto the PATH. +Source: "{#BuildDir}\shellrs-cli.exe"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +Name: "{autoprograms}\ShellRS"; Filename: "{app}\shellrs.exe" +Name: "{autodesktop}\ShellRS"; Filename: "{app}\shellrs.exe"; Tasks: desktopicon + +[Run] +; Interactive installs only: an update is restarted by ShellRS's own helper, +; which waits for this setup to finish. +Filename: "{app}\shellrs.exe"; Description: "{cm:LaunchProgram,ShellRS}"; Flags: nowait postinstall skipifsilent + +[UninstallDelete] +; Files a later version added, which this uninstaller does not list. +Type: filesandordirs; Name: "{app}" diff --git a/src/app/actions.rs b/src/app/actions.rs index 5e20459..ccb3203 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -95,6 +95,16 @@ gpui_kit::actions!( RefreshCliIntegration, /// Copy the agent skill's text to the clipboard. CopyAgentSkill, + /// Look for a newer ShellRS now. + CheckForUpdates, + /// Download the newer ShellRS that was found. + DownloadUpdate, + /// Show the newer ShellRS: what changed, and restarting into it. + ShowUpdate, + /// Restart into the downloaded ShellRS. + RestartToUpdate, + /// Open the page to download ShellRS by hand. + OpenDownloadPage, ] ); diff --git a/src/app/app_icon.rs b/src/app/app_icon.rs new file mode 100644 index 0000000..0c67cfc --- /dev/null +++ b/src/app/app_icon.rs @@ -0,0 +1,34 @@ +//! The logo as the Dock icon of a ShellRS that is not in an app bundle. +//! +//! A packaged ShellRS takes its icon from the bundle (`ShellRS.icns`, see +//! `packaging/macos`). Run straight from `cargo run` there is no bundle, +//! and macOS shows a generic program icon; this sets the logo at runtime +//! instead. Windows reads the icon built into the executable (`build.rs`); +//! Linux takes it from the desktop entry the AppImage carries. + +/// Show the logo in the Dock when ShellRS runs outside an app bundle. +#[cfg(target_os = "macos")] +pub fn show_logo_when_unbundled(cx: &gpui_kit::App) { + use objc2::{AnyThread as _, MainThreadMarker}; + use objc2_app_kit::{NSApplication, NSImage}; + use objc2_foundation::NSData; + + let bundled = cx + .app_path() + .is_ok_and(|path| path.extension().is_some_and(|extension| extension == "app")); + if bundled { + return; + } + let Some(main_thread) = MainThreadMarker::new() else { + return; + }; + let data = NSData::with_bytes(include_bytes!("../../assets/logo/shellrs.png")); + let Some(image) = NSImage::initWithData(NSImage::alloc(), &data) else { + return; + }; + // SAFETY: a valid image, set on the main thread. + unsafe { NSApplication::sharedApplication(main_thread).setApplicationIconImage(Some(&image)) }; +} + +#[cfg(not(target_os = "macos"))] +pub fn show_logo_when_unbundled(_: &gpui_kit::App) {} diff --git a/src/app/assets.rs b/src/app/assets.rs index 4c3f8e7..08625b6 100644 --- a/src/app/assets.rs +++ b/src/app/assets.rs @@ -46,7 +46,8 @@ icon_assets!( KeyRound, RectangleEllipsis, FileKey, - UserRoundKey + UserRoundKey, + CircleArrowUp ] ); diff --git a/src/app/mod.rs b/src/app/mod.rs index 3f7d680..fc5c9e2 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,12 +1,16 @@ //! Application-level wiring: actions, key bindings, assets. mod actions; +mod app_icon; mod assets; mod paths; pub use actions::*; +pub use app_icon::show_logo_when_unbundled; pub use assets::{AppAssets, CatalogIcon}; -pub use paths::{cli_endpoint, cli_socket_path, data_dir, database_path, keys_dir, settings_path}; +pub use paths::{ + cli_endpoint, cli_socket_path, data_dir, database_path, keys_dir, settings_path, updates_dir, +}; use gpui_kit::component::{Theme, dock::ToggleZoom}; use gpui_kit::*; diff --git a/src/app/paths.rs b/src/app/paths.rs index 53d3bc8..f1204e3 100644 --- a/src/app/paths.rs +++ b/src/app/paths.rs @@ -31,6 +31,18 @@ pub fn keys_dir() -> PathBuf { data_dir().join("keys") } +/// Where updates are downloaded to: the user's cache folder. With +/// `SHELLRS_DATA_DIR` set it is beside the data instead, so a test or +/// development instance never shares downloads with the installed app. +pub fn updates_dir() -> PathBuf { + if std::env::var_os(DATA_DIR_ENV).is_some() { + return data_dir().join("updates"); + } + dirs::cache_dir() + .map(|dir| dir.join("shellrs").join("updates")) + .unwrap_or_else(|| data_dir().join("updates")) +} + /// The interface settings, a small JSON file beside the database. pub fn settings_path() -> std::io::Result { let dir = data_dir(); diff --git a/src/cli/install.rs b/src/cli/install.rs index dd4865e..74a8622 100644 --- a/src/cli/install.rs +++ b/src/cli/install.rs @@ -106,7 +106,10 @@ impl IntegrationPaths { /// Windows gets a folder of ShellRS's own, added to the user's PATH. pub fn system() -> Option { let home = dirs::home_dir()?; - let exe = std::env::current_exe().ok()?; + let exe = program( + std::env::current_exe().ok()?, + std::env::var_os("APPIMAGE").map(PathBuf::from), + ); let (bin_link, exe, user_path) = if cfg!(target_os = "macos") { ( PathBuf::from("/usr/local/bin").join(COMMAND_NAME), @@ -140,6 +143,16 @@ impl IntegrationPaths { } } +/// The file the `shellrs` command runs. Inside an AppImage the executable +/// lives in a mount that is gone once the app exits, so the command runs +/// the AppImage itself, which `$APPIMAGE` names. +fn program(exe: PathBuf, appimage: Option) -> PathBuf { + match appimage { + Some(appimage) if cfg!(target_os = "linux") || cfg!(test) => appimage, + _ => exe, + } +} + /// What is at [`IntegrationPaths::bin_link`]. #[derive(Clone, Debug, PartialEq, Eq)] pub enum BinaryStatus { @@ -352,6 +365,20 @@ pub fn install_skill(paths: &IntegrationPaths, agent: AgentKind) -> io::Result<( fs::rename(&temporary, &file) } +/// Rewrite the skills that are installed but come from another version of +/// ShellRS, so that an update reaches the agents too. Skills nobody +/// installed stay uninstalled. Returns how many were rewritten. +pub fn update_outdated_skills(paths: &IntegrationPaths) -> io::Result { + let mut updated = 0; + for agent in AgentKind::ALL { + if skill_status(paths, agent) == SkillStatus::Outdated { + install_skill(paths, agent)?; + updated += 1; + } + } + Ok(updated) +} + /// Delete the skill, and its folder once empty. The agent's own /// directories stay. pub fn remove_skill(paths: &IntegrationPaths, agent: AgentKind) -> io::Result<()> { @@ -374,6 +401,14 @@ mod tests { use super::*; + #[test] + fn inside_an_appimage_the_command_links_to_the_appimage() { + let mounted = PathBuf::from("/tmp/.mount_ShellRabc/usr/bin/shellrs"); + let appimage = PathBuf::from("/home/me/Apps/ShellRS-x86_64.AppImage"); + assert_eq!(program(mounted.clone(), Some(appimage.clone())), appimage); + assert_eq!(program(mounted.clone(), None), mounted); + } + fn paths(root: &Path) -> IntegrationPaths { let exe = root.join("app").join("shellrs"); fs::create_dir_all(exe.parent().unwrap()).unwrap(); @@ -440,6 +475,20 @@ mod tests { remove_skill(&paths, agent).unwrap(); } + #[test] + fn outdated_skills_are_rewritten_and_missing_ones_left_alone() { + let root = tempfile::tempdir().unwrap(); + let paths = paths(root.path()); + let [first, second, ..] = AgentKind::ALL; + install_skill(&paths, first).unwrap(); + fs::write(paths.skill_file(first), "an older skill").unwrap(); + + assert_eq!(update_outdated_skills(&paths).unwrap(), 1); + assert_eq!(skill_status(&paths, first), SkillStatus::Installed); + assert_eq!(skill_status(&paths, second), SkillStatus::Missing); + assert_eq!(update_outdated_skills(&paths).unwrap(), 0); + } + #[cfg(unix)] #[test] fn the_command_links_to_this_program_and_is_removed_again() { diff --git a/src/cli/integration.rs b/src/cli/integration.rs index b38d7b1..29291cd 100644 --- a/src/cli/integration.rs +++ b/src/cli/integration.rs @@ -2,7 +2,7 @@ use gpui_kit::*; use super::install::{ AgentKind, BinaryStatus, IntegrationPaths, SkillStatus, binary_status, skill_status, - update_outdated_binary, + update_outdated_binary, update_outdated_skills, }; /// What the settings page shows about the `shellrs` command and the agent @@ -72,8 +72,9 @@ impl CliIntegration { } /// Bring a copy of the command left by an older build up to this one, - /// then look again. A copy that old answers every request with - /// `version_mismatch`. Quietly: if it fails, the page offers 更新. + /// and the installed agent skills with it, then look again. A copy that + /// old answers every request with `version_mismatch`. Quietly: if it + /// fails, the page offers 更新. pub fn update_outdated(&mut self, cx: &mut Context) { self.check(true, cx); } @@ -89,6 +90,7 @@ impl CliIntegration { .background_spawn(async move { if update { let _ = update_outdated_binary(&paths); + let _ = update_outdated_skills(&paths); } IntegrationStatus::read(&paths) }) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 89796d1..ebdd30b 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -29,7 +29,7 @@ pub use client::{Console, FAILURE_EXIT, PARTIAL_EXIT, activate_running_app}; pub use install::{ AgentKind, BinaryStatus, IntegrationPaths, SKILL, SkillStatus, UserPath, binary_status, install_binary, install_skill, remove_binary, remove_skill, skill_status, - update_outdated_binary, + update_outdated_binary, update_outdated_skills, }; pub use integration::{CliIntegration, IntegrationStatus}; pub use protocol::{CliError, ErrorCode, Request, SessionInfo, TransferCounters, TransferSummary}; diff --git a/src/lib.rs b/src/lib.rs index 920b63f..7ca5822 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,8 +3,9 @@ //! Modules are organized by capability so they can become crates later: //! `session` (主机管理), `terminal` (终端), `ssh` (SSH 连接), `sftp` (SFTP //! 传输), `explorer` (SFTP 文件浏览), `forward` (端口转发), `credential` -//! (凭据), `secrets` (系统钥匙串), `settings` (设置), `cli` (外部 CLI), `connection` 与 -//! `shared` (跨模块共用), `workspace` (窗口壳), `app` (动作、快捷键、资源). +//! (凭据), `secrets` (系统钥匙串), `settings` (设置), `cli` (外部 CLI), `update` +//! (在线升级), `connection` 与 `shared` (跨模块共用), `workspace` (窗口壳), `app` +//! (动作、快捷键、资源). pub mod app; pub mod cli; @@ -19,6 +20,7 @@ pub mod sftp; pub mod shared; pub mod ssh; pub mod terminal; +pub mod update; pub mod workspace; /// Initialize GPUI Kit, locale, key bindings and global actions. Call once diff --git a/src/main.rs b/src/main.rs index b9d332c..757a1bd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,7 @@ fn main() { .with_assets(shellrs::app::AppAssets) .run(|cx| { shellrs::init(cx); + shellrs::app::show_logo_when_unbundled(cx); cx.activate(true); let options = shellrs::workspace::window_options(cx); @@ -53,6 +54,9 @@ fn main() { }) .detach(); }); + // On Windows the event loop ends and `run` returns: an update that is + // to be installed now starts its setup, which waits for this process. + shellrs::update::start_handed_over(); } /// Load the sessions saved on disk. If the database cannot be opened the diff --git a/src/session/database.rs b/src/session/database.rs index dd7842f..4de31f0 100644 --- a/src/session/database.rs +++ b/src/session/database.rs @@ -903,8 +903,14 @@ fn write_route(connection: &Connection, session: &Session) -> rusqlite::Result<( } /// Bring the database to `SCHEMA_VERSION`. A new file has version 0 and gets -/// the whole schema; an older one is taken forward one version at a time. -/// Each step is one transaction, so a file is never left between versions. +/// the whole schema; an older one is backed up, then taken forward one +/// version at a time. Each step is one transaction, so a file is never left +/// between versions. +/// +/// A file from a newer ShellRS is refused and left as it is: this version +/// does not know what the newer tables mean, and writing to them could lose +/// what the newer version saved. That happens after going back to an older +/// version by hand, the one way an update can be undone. fn migrate(connection: &Connection) -> rusqlite::Result<()> { let mut version: i64 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; if version == 0 { @@ -912,6 +918,18 @@ fn migrate(connection: &Connection) -> rusqlite::Result<()> { "BEGIN;\n{SCHEMA}\nPRAGMA user_version = {SCHEMA_VERSION};\nCOMMIT;" )); } + if version > SCHEMA_VERSION { + return Err(rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR), + Some(format!( + "数据库来自更新版本的 ShellRS(结构版本 {version},本版本支持到 \ + {SCHEMA_VERSION}),请安装最新版本" + )), + )); + } + if version < SCHEMA_VERSION { + back_up(connection, version); + } for (from, step) in STEPS { if version == from { match step { @@ -924,6 +942,22 @@ fn migrate(connection: &Connection) -> rusqlite::Result<()> { Ok(()) } +/// Copy the database to `.v.bak` before migrating it, so the +/// version that wrote it can still open its data. `VACUUM INTO` writes a +/// consistent copy; copying the file would miss what is still in the WAL. +/// A failed backup does not stop the migration: the data is still there, +/// only the way back is not. +fn back_up(connection: &Connection, version: i64) { + let Some(path) = connection.path().filter(|path| !path.is_empty()) else { + return; + }; + let backup = format!("{path}.v{version}.bak"); + let _ = std::fs::remove_file(&backup); + if let Err(error) = connection.execute("VACUUM INTO ?1", [&backup]) { + eprintln!("shellrs: 迁移前无法备份数据库:{error}"); + } +} + /// The step from version 9: a host logs in on its own with a password or /// with nothing typed, and a private key is always a credential's. /// @@ -1305,6 +1339,75 @@ PRAGMA user_version = 9;"; assert_eq!(again.credentials.len(), 3); } + #[test] + fn a_database_from_a_newer_shellrs_is_refused_untouched() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("shellrs.db"); + drop(SessionDatabase::open(&path).unwrap()); + let newer = SCHEMA_VERSION + 1; + Connection::open(&path) + .unwrap() + .pragma_update(None, "user_version", newer) + .unwrap(); + + let Err(error) = SessionDatabase::open(&path) else { + panic!("a newer database opened"); + }; + assert!(error.to_string().contains("更新版本的 ShellRS"), "{error}"); + let version: i64 = Connection::open(&path) + .unwrap() + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap(); + assert_eq!(version, newer); + assert!(!dir.path().join(format!("shellrs.db.v{newer}.bak")).exists()); + } + + #[test] + fn an_older_database_is_backed_up_before_it_is_migrated() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("shellrs.db"); + { + let old = Connection::open(&path).unwrap(); + old.execute_batch(SCHEMA_V7).unwrap(); + old.execute_batch(SCHEMA_V8_FORWARDS).unwrap(); + old.execute_batch(SCHEMA_V9_CREDENTIALS).unwrap(); + old.execute_batch( + "INSERT INTO sessions (id, name, host, port, username, auth, public_id) \ + VALUES (1, 'web', 'example.test', 22, 'root', 'password', 'abcdefgh12345678'); + PRAGMA user_version = 10;", + ) + .unwrap(); + } + drop(SessionDatabase::open(&path).unwrap()); + + let backup = Connection::open(dir.path().join("shellrs.db.v10.bak")).unwrap(); + let version: i64 = backup + .pragma_query_value(None, "user_version", |row| row.get(0)) + .unwrap(); + assert_eq!(version, 10); + let name: String = backup + .query_row("SELECT name FROM sessions WHERE id = 1", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(name, "web"); + } + + #[test] + fn a_new_or_current_database_needs_no_backup() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("shellrs.db"); + drop(SessionDatabase::open(&path).unwrap()); + drop(SessionDatabase::open(&path).unwrap()); + let files: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .flatten() + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .filter(|name| name.ends_with(".bak")) + .collect(); + assert!(files.is_empty(), "{files:?}"); + } + /// The route tables version 11 added, frozen like `SCHEMA_V7`. const SCHEMA_V11_ROUTES: &str = " CREATE TABLE session_jumps ( diff --git a/src/settings/mod.rs b/src/settings/mod.rs index de9d4a0..c78ffd9 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -8,6 +8,7 @@ mod store; pub use apply::apply; pub use model::{ AppSettings, Appearance, Choice, ExternalCliSettings, InterfaceLanguage, TerminalFontSettings, + UpdateSettings, }; pub use settings_panel::{SettingsPanel, SettingsPanelEvent}; pub use store::{SettingsStore, SettingsStoreEvent}; diff --git a/src/settings/model.rs b/src/settings/model.rs index 89e496e..f5caee1 100644 --- a/src/settings/model.rs +++ b/src/settings/model.rs @@ -15,6 +15,7 @@ pub struct AppSettings { pub appearance: Appearance, pub terminal_font: TerminalFontSettings, pub external_cli: ExternalCliSettings, + pub update: UpdateSettings, /// The SFTP 大小 column, switched from the column title's menu rather /// than the settings page, as in WinSCP. pub file_size_format: FileSizeFormat, @@ -29,6 +30,21 @@ 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. + pub automatic: bool, +} + +impl Default for UpdateSettings { + fn default() -> Self { + Self { automatic: true } + } +} + impl AppSettings { /// The settings with every number in the range the app accepts. Applied /// to what the file holds and to every change, so neither a hand-edited @@ -230,6 +246,17 @@ mod tests { assert_eq!(settings.file_size_format, FileSizeFormat::Short); } + #[test] + fn automatic_updates_are_on_unless_the_file_says_otherwise() { + let settings: AppSettings = serde_json::from_str("{}").unwrap(); + assert!(settings.update.automatic); + let settings: AppSettings = serde_json::from_str(r#"{"update":{}}"#).unwrap(); + assert!(settings.update.automatic); + let settings: AppSettings = + serde_json::from_str(r#"{"update":{"automatic":false}}"#).unwrap(); + assert!(!settings.update.automatic); + } + #[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 5e19201..d9dbc93 100644 --- a/src/settings/settings_panel.rs +++ b/src/settings/settings_panel.rs @@ -4,17 +4,20 @@ use gpui_kit::component::{ dock::{BasePanel, Panel, PanelEvent, TabGroup}, group_box::GroupBoxVariant, h_flex, + link::Link, menu::PopupMenu, separator::Separator, setting::{NumberFieldOptions, SettingField, SettingGroup, SettingItem, SettingPage, Settings}, + text::TextView, v_flex, }; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::app::{ - CatalogIcon, CenterTab, CloseSettings, CopyAgentSkill, InstallAgentSkill, InstallCliCommand, - RefreshCliIntegration, RemoveAgentSkill, RemoveCliCommand, + CatalogIcon, CenterTab, CheckForUpdates, CloseSettings, CopyAgentSkill, DownloadUpdate, + InstallAgentSkill, InstallCliCommand, OpenDownloadPage, RefreshCliIntegration, + RemoveAgentSkill, RemoveCliCommand, ShowUpdate, }; use crate::cli::{AgentKind, BinaryStatus, CliIntegration, IntegrationStatus, SkillStatus}; use crate::shared::{ClosableTabTitle, close_tab_items}; @@ -22,6 +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 super::{AppSettings, Choice, SettingsStore}; @@ -42,12 +46,14 @@ pub struct SettingsPanel { store: Entity, /// What of the external CLI is installed, for 外部 CLI. integration: Entity, + /// Where updating stands, for 关于. + updater: Entity, /// The monospace families to choose the terminal font from; empty until /// the scan in the background comes back. font_families: &'static [SharedString], focus_handle: FocusHandle, tab_group: Option>, - _subscriptions: [Subscription; 2], + _subscriptions: [Subscription; 3], _font_scan: Task<()>, } @@ -55,11 +61,13 @@ impl SettingsPanel { pub fn new( store: Entity, integration: Entity, + updater: Entity, cx: &mut Context, ) -> Self { let subscriptions = [ cx.observe(&store, |_, _, cx| cx.notify()), cx.observe(&integration, |_, _, cx| cx.notify()), + cx.observe(&updater, |_, _, cx| cx.notify()), ]; // Loading every family takes a moment the first time; the process // keeps the answer, so later tabs have it at once. @@ -76,6 +84,7 @@ impl SettingsPanel { Self { store, integration, + updater, font_families: &[], focus_handle: cx.focus_handle(), tab_group: None, @@ -191,7 +200,7 @@ struct Category { type CategoryGroups = fn(&SettingsPanel, &App) -> Vec; /// The categories, in the order the left column lists them. -const CATEGORIES: [Category; 3] = [ +const CATEGORIES: [Category; 4] = [ Category { title: "外观", icon: CatalogIcon::Palette, @@ -207,6 +216,11 @@ const CATEGORIES: [Category; 3] = [ icon: CatalogIcon::SquareTerminal, groups: external_cli_groups, }, + Category { + title: "关于", + icon: CatalogIcon::Info, + groups: about_groups, + }, ]; impl Category { @@ -360,6 +374,195 @@ fn external_cli_groups(panel: &SettingsPanel, cx: &App) -> Vec { ] } +/// 关于: which ShellRS this is, and updating it. +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), + SettingItem::new( + "自动检查并下载更新", + SettingField::switch( + move |cx| reader.read(cx).settings().update.automatic, + move |automatic, cx| { + writer.update(cx, |store, cx| { + store.update(|settings| settings.update.automatic = automatic, cx) + }); + }, + ) + .default_value(true), + ) + .description( + "在后台检查新版本并下载,下载完成后在标题栏右上角提示,退出或重启时安装。\ + 检查时只发送 ShellRS 的版本号、系统和架构。", + ), + ]), + ]; + 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| { + 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(( + "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() + .gap_3() + .child( + v_flex() + .gap_1() + .min_w_0() + .child(div().text_sm().child("状态:")) + .child( + div() + .id("update-status") + .test_support() + .aria_label(text.clone()) + .text_sm() + .text_color(color) + .child(text), + ), + ) + .child( + h_flex() + .gap_2() + .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), + ) + })), + ) + }) + .keywords(["更新", "升级", "版本", "下载"]) +} + /// Where the `shellrs` command stands, with the one thing to do about it. fn binary_item(bin_link: Option, status: Option) -> SettingItem { SettingItem::render(move |_, _, cx| { diff --git a/src/update/build_info.rs b/src/update/build_info.rs new file mode 100644 index 0000000..0992662 --- /dev/null +++ b/src/update/build_info.rs @@ -0,0 +1,99 @@ +//! What this build is, as stamped by the release workflow. +//! +//! 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. + +use serde::Deserialize; + +/// This build's version, from `Cargo.toml`. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// The commit the release was built from. +pub const COMMIT: Option<&str> = option_env!("SHELLRS_COMMIT"); + +/// The day the release was built, `YYYY-MM-DD`. +pub const BUILD_DATE: Option<&str> = option_env!("SHELLRS_BUILD_DATE"); + +/// The Apple team that signs ShellRS: a new bundle must come from it. +pub const APPLE_TEAM_ID: Option<&str> = option_env!("SHELLRS_APPLE_TEAM_ID"); + +/// Where each channel's signed manifest lives. Every ShellRS ever shipped +/// asks here, so the address never changes; a new manifest format gets a +/// new `schema`, not a new path. +pub const MANIFEST_URL: &str = "https://dl.shellrs.com/update/v1/{channel}.json"; + +/// Where a person downloads ShellRS by hand. +pub const DOWNLOAD_PAGE: &str = "https://shellrs.com/download"; + +/// The minisign public keys a manifest may be signed with: the key in use, +/// and a spare kept offline so the first can be replaced. Rotating means +/// shipping a release that knows the new key before signing with it. +/// +/// Empty until the release keys exist (see the release section of +/// `README.md`); the release workflow refuses to build a release without +/// them, and a build without them rejects every manifest. +pub const TRUSTED_KEYS: &[&str] = &[]; + +/// Which stream of releases a build follows. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Channel { + Stable, + Beta, +} + +impl Channel { + /// The name in the manifest's path and in its signature. + pub fn key(self) -> &'static str { + match self { + Self::Stable => "stable", + Self::Beta => "beta", + } + } + + pub fn from_key(key: &str) -> Option { + match key { + "stable" => Some(Self::Stable), + "beta" => Some(Self::Beta), + _ => None, + } + } + + /// The channel this build follows; `None` for a development build. + pub fn of_this_build() -> Option { + option_env!("SHELLRS_UPDATE_CHANNEL").and_then(Self::from_key) + } + + pub fn manifest_url(self) -> String { + MANIFEST_URL.replace("{channel}", self.key()) + } +} + +/// This build's version, parsed. +pub fn version() -> semver::Version { + semver::Version::parse(VERSION).expect("Cargo.toml holds a semver version") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_channel_has_a_manifest_of_its_own() { + assert_eq!( + Channel::Stable.manifest_url(), + "https://dl.shellrs.com/update/v1/stable.json" + ); + assert_eq!( + Channel::Beta.manifest_url(), + "https://dl.shellrs.com/update/v1/beta.json" + ); + assert_eq!(Channel::from_key("nightly"), None); + } + + #[test] + fn the_package_version_parses() { + assert_eq!(version().to_string(), VERSION); + } +} diff --git a/src/update/error.rs b/src/update/error.rs new file mode 100644 index 0000000..8c15a44 --- /dev/null +++ b/src/update/error.rs @@ -0,0 +1,54 @@ +//! What can go wrong while checking for, fetching or installing an update, +//! worded for the person reading 设置 › 关于 or the update dialog. + +use std::fmt; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum UpdateError { + /// The server could not be reached, or the transfer broke off. + Network(String), + /// The server answered, but not with the file. + Http(u16), + /// Nothing arrived for a while in the middle of a download. + Stalled, + /// The manifest is not signed by a key this build trusts, or was + /// signed for another channel or version. + BadSignature, + /// The manifest could not be read. + BadManifest(String), + /// The downloaded file is not the one the manifest describes. + Corrupt, + /// Not enough room for the download and the unpacked copy. + NoSpace, + /// Reading or writing a local file failed. + Disk(String), + /// Putting the new version in place failed. + Install(String), + /// Stopped on purpose: a newer check, or automatic updates turned off. + Cancelled, +} + +impl fmt::Display for UpdateError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Network(reason) => write!(f, "无法连接更新服务器:{reason}"), + Self::Http(status) => write!(f, "更新服务器返回 HTTP {status}"), + Self::Stalled => f.write_str("下载长时间没有进展,已中断"), + Self::BadSignature => f.write_str("更新清单的签名无效"), + Self::BadManifest(reason) => write!(f, "无法读取更新清单:{reason}"), + Self::Corrupt => f.write_str("下载的文件与更新清单不符"), + Self::NoSpace => f.write_str("磁盘空间不足"), + Self::Disk(reason) => write!(f, "无法写入更新文件:{reason}"), + Self::Install(reason) => write!(f, "无法安装新版本:{reason}"), + Self::Cancelled => f.write_str("已取消"), + } + } +} + +impl std::error::Error for UpdateError {} + +impl From for UpdateError { + fn from(error: std::io::Error) -> Self { + Self::Disk(error.to_string()) + } +} diff --git a/src/update/feed.rs b/src/update/feed.rs new file mode 100644 index 0000000..2b3fc8e --- /dev/null +++ b/src/update/feed.rs @@ -0,0 +1,582 @@ +//! Fetching manifests and packages. +//! +//! Every method blocks and runs on an update worker thread. The feed only +//! moves bytes: whether to believe them is `verify`'s job, so a feed cannot +//! skip the check, and the fake one in the tests goes through it too. + +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use reqwest::StatusCode; +use reqwest::blocking::{Client, Response}; +use reqwest::header::{CONTENT_RANGE, RANGE}; + +use super::build_info::{self, Channel}; +use super::error::UpdateError; +use super::platform; + +/// Where manifests and packages come from. +pub trait UpdateFeed: Send + Sync { + /// The signed envelope for `channel`, as served. + fn fetch(&self, channel: Channel) -> Result, UpdateError>; + + /// Download a package of `size` bytes into `dest`, trying `urls` in + /// order. Bytes already in `dest`'s `.part` file are kept and the rest + /// is asked for with a range. `progress` hears the bytes on disk so far. + fn download( + &self, + urls: &[String], + size: u64, + dest: &Path, + progress: &mut dyn FnMut(u64), + cancel: &AtomicBool, + ) -> Result<(), UpdateError>; +} + +/// Where a download in progress is kept: next to its destination, so that +/// finishing it is a rename. +pub fn part_path(dest: &Path) -> PathBuf { + let mut name = dest.file_name().unwrap_or_default().to_os_string(); + name.push(".part"); + dest.with_file_name(name) +} + +/// The largest manifest believed to be one. +const MANIFEST_LIMIT: u64 = 1024 * 1024; + +/// The real feed, over HTTPS. +pub struct HttpFeed { + /// The manifest's address, with `{channel}` for the channel. + manifest_url: String, + /// How long a connection may stay silent, while connecting, waiting for + /// an answer or in the middle of a download. + patience: Duration, + /// Whether to go through the system's proxy. Only the loopback tests turn + /// it off: a developer's proxy would capture 127.0.0.1. + system_proxy: bool, +} + +impl HttpFeed { + /// The feed release builds use. A debug build can be pointed at a local + /// server with `SHELLRS_UPDATE_URL`; a release build cannot, so nothing + /// in the environment decides where ShellRS updates itself from. + pub fn system() -> Self { + let override_url = cfg!(debug_assertions) + .then(|| std::env::var("SHELLRS_UPDATE_URL").ok()) + .flatten(); + Self::new(override_url.unwrap_or_else(|| build_info::MANIFEST_URL.to_string())) + } + + pub fn new(manifest_url: String) -> Self { + Self { + manifest_url, + patience: Duration::from_secs(30), + system_proxy: true, + } + } + + /// Built on the worker thread, per job: the blocking client runs a + /// runtime of its own, which must not be created or dropped inside an + /// async context. + fn client(&self) -> Result { + let builder = Client::builder() + .user_agent(platform::user_agent(build_info::VERSION)) + .connect_timeout(self.patience) + // Per read, in the blocking client: a download that stops + // moving fails after this long, a slow one keeps going. + .timeout(self.patience); + let builder = if self.system_proxy { + builder + } else { + builder.no_proxy() + }; + builder + .build() + .map_err(|error| UpdateError::Network(describe(&error))) + } + + fn download_from( + &self, + client: &Client, + url: &str, + size: u64, + part: &Path, + progress: &mut dyn FnMut(u64), + cancel: &AtomicBool, + ) -> Result<(), UpdateError> { + let mut have = fs::metadata(part).map(|meta| meta.len()).unwrap_or(0); + if have > size { + fs::remove_file(part)?; + have = 0; + } + if have == size { + return Ok(()); + } + let mut request = client.get(url); + if have > 0 { + request = request.header(RANGE, format!("bytes={have}-")); + } + let mut response = request + .send() + .map_err(|error| UpdateError::Network(describe(&error)))?; + let mut file = match response.status() { + StatusCode::PARTIAL_CONTENT if have > 0 => { + if range_start(&response) != Some(have) { + // Not the bytes that were asked for: start over next time. + fs::remove_file(part)?; + return Err(UpdateError::Network("服务器返回的续传位置不对".into())); + } + OpenOptions::new().append(true).open(part)? + } + // The whole file, also from a server that ignores ranges. + StatusCode::OK => { + have = 0; + File::create(part)? + } + status => return Err(UpdateError::Http(status.as_u16())), + }; + progress(have); + let mut buffer = vec![0; 64 * 1024]; + loop { + if cancel.load(Ordering::Relaxed) { + return Err(UpdateError::Cancelled); + } + let read = response.read(&mut buffer).map_err(read_error)?; + if read == 0 { + break; + } + have += read as u64; + if have > size { + drop(file); + fs::remove_file(part)?; + return Err(UpdateError::Corrupt); + } + file.write_all(&buffer[..read]).map_err(write_error)?; + progress(have); + } + file.sync_all().map_err(write_error)?; + if have < size { + return Err(UpdateError::Network("连接在下载完成前断开".into())); + } + Ok(()) + } +} + +impl UpdateFeed for HttpFeed { + fn fetch(&self, channel: Channel) -> Result, UpdateError> { + let client = self.client()?; + let url = self.manifest_url.replace("{channel}", channel.key()); + let response = client + .get(url) + .send() + .map_err(|error| UpdateError::Network(describe(&error)))?; + if !response.status().is_success() { + return Err(UpdateError::Http(response.status().as_u16())); + } + let mut body = Vec::new(); + response + .take(MANIFEST_LIMIT + 1) + .read_to_end(&mut body) + .map_err(read_error)?; + if body.len() as u64 > MANIFEST_LIMIT { + return Err(UpdateError::BadManifest("文件过大".into())); + } + Ok(body) + } + + fn download( + &self, + urls: &[String], + size: u64, + dest: &Path, + progress: &mut dyn FnMut(u64), + cancel: &AtomicBool, + ) -> Result<(), UpdateError> { + let client = self.client()?; + let part = part_path(dest); + let mut last = UpdateError::Network("没有下载地址".into()); + for url in urls { + match self.download_from(&client, url, size, &part, progress, cancel) { + Ok(()) => { + fs::rename(&part, dest)?; + return Ok(()); + } + Err( + error @ (UpdateError::Cancelled | UpdateError::Disk(_) | UpdateError::NoSpace), + ) => { + return Err(error); + } + // The next address may be a mirror that works; what is on + // disk is kept, the checksum decides in the end. + Err(error) => last = error, + } + } + Err(last) + } +} + +/// The first byte of a `206` answer, from `Content-Range: bytes 10-99/100`. +fn range_start(response: &Response) -> Option { + let value = response.headers().get(CONTENT_RANGE)?.to_str().ok()?; + let range = value.strip_prefix("bytes ")?; + range.split('-').next()?.trim().parse().ok() +} + +/// A failed read of a response body. A timeout arrives as reqwest's own +/// error wrapped in an `io::Error`, worded as a decoding error. +fn read_error(error: io::Error) -> UpdateError { + let timed_out = error.kind() == io::ErrorKind::TimedOut + || error + .get_ref() + .and_then(|inner| inner.downcast_ref::()) + .is_some_and(reqwest::Error::is_timeout); + if timed_out { + return UpdateError::Stalled; + } + match error + .get_ref() + .and_then(|inner| inner.downcast_ref::()) + { + Some(inner) => UpdateError::Network(describe(inner)), + None => UpdateError::Network(error.to_string()), + } +} + +fn write_error(error: io::Error) -> UpdateError { + if error.kind() == io::ErrorKind::StorageFull { + UpdateError::NoSpace + } else { + UpdateError::Disk(error.to_string()) + } +} + +/// A request error in a few words: reqwest's own message only names the +/// URL, the reason is at the end of its chain. +fn describe(error: &reqwest::Error) -> String { + if error.is_timeout() { + return "连接超时".into(); + } + let mut source: &dyn std::error::Error = error; + while let Some(next) = source.source() { + source = next; + } + source.to_string() +} + +#[cfg(test)] +pub(crate) mod tests { + use std::collections::HashMap; + use std::io::{BufRead, BufReader}; + use std::net::{TcpListener, TcpStream}; + use std::sync::{Arc, Mutex}; + use std::thread; + + use super::*; + + /// How the loopback server answers a path. + #[derive(Clone)] + pub(crate) enum Answer { + /// The body, honouring ranges. + Body(Vec), + /// The whole body whatever the range. + IgnoringRange(Vec), + /// The headers of the whole body, then only its first bytes. + CutAfter(Vec, usize), + /// The headers, then nothing. + Silence(Vec), + Status(u16), + } + + /// A request as the server saw it. + #[derive(Clone, Debug)] + pub(crate) struct Seen { + pub path: String, + pub range: Option, + pub user_agent: Option, + } + + /// A small HTTP/1.1 server on 127.0.0.1, one connection per request. + pub(crate) struct Server { + port: u16, + answers: Arc>>, + seen: Arc>>, + } + + impl Server { + pub(crate) fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let answers = Arc::new(Mutex::new(HashMap::new())); + let seen = Arc::new(Mutex::new(Vec::new())); + let (shared_answers, shared_seen) = (answers.clone(), seen.clone()); + thread::spawn(move || { + for stream in listener.incoming().flatten() { + let (answers, seen) = (shared_answers.clone(), shared_seen.clone()); + thread::spawn(move || serve(stream, &answers, &seen)); + } + }); + Self { + port, + answers, + seen, + } + } + + pub(crate) fn url(&self, path: &str) -> String { + format!("http://127.0.0.1:{}{path}", self.port) + } + + pub(crate) fn answer(&self, path: &str, answer: Answer) { + self.answers.lock().unwrap().insert(path.into(), answer); + } + + pub(crate) fn seen(&self) -> Vec { + self.seen.lock().unwrap().clone() + } + + /// A feed reading this server's `/{channel}.json`. + pub(crate) fn feed(&self) -> HttpFeed { + HttpFeed { + manifest_url: self.url("/{channel}.json"), + patience: Duration::from_millis(500), + system_proxy: false, + } + } + } + + fn serve(stream: TcpStream, answers: &Mutex>, seen: &Mutex>) { + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut request_line = String::new(); + if reader.read_line(&mut request_line).is_err() { + return; + } + let path = request_line + .split_whitespace() + .nth(1) + .unwrap_or("/") + .to_string(); + let (mut range, mut user_agent) = (None, None); + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 || line.trim().is_empty() { + break; + } + if let Some((name, value)) = line.split_once(':') { + match name.trim().to_ascii_lowercase().as_str() { + "range" => range = Some(value.trim().to_string()), + "user-agent" => user_agent = Some(value.trim().to_string()), + _ => {} + } + } + } + seen.lock().unwrap().push(Seen { + path: path.clone(), + range: range.clone(), + user_agent, + }); + let answer = answers.lock().unwrap().get(&path).cloned(); + let mut stream = stream; + let head = |status: &str, length: usize, extra: &str| { + format!( + "HTTP/1.1 {status}\r\nContent-Length: {length}\r\nConnection: close\r\n{extra}\r\n" + ) + }; + let start = range + .as_deref() + .and_then(|range| range.strip_prefix("bytes=")) + .and_then(|range| range.trim_end_matches('-').parse::().ok()); + let _ = match answer { + None | Some(Answer::Status(404)) => { + stream.write_all(head("404 Not Found", 0, "").as_bytes()) + } + Some(Answer::Status(code)) => { + stream.write_all(head(&format!("{code} Whatever"), 0, "").as_bytes()) + } + Some(Answer::Body(body)) => match start { + Some(start) if start < body.len() => { + let extra = format!( + "Content-Range: bytes {start}-{}/{}\r\n", + body.len() - 1, + body.len() + ); + stream + .write_all( + head("206 Partial Content", body.len() - start, &extra).as_bytes(), + ) + .and_then(|()| stream.write_all(&body[start..])) + } + _ => stream + .write_all(head("200 OK", body.len(), "").as_bytes()) + .and_then(|()| stream.write_all(&body)), + }, + Some(Answer::IgnoringRange(body)) => stream + .write_all(head("200 OK", body.len(), "").as_bytes()) + .and_then(|()| stream.write_all(&body)), + Some(Answer::CutAfter(body, cut)) => stream + .write_all(head("200 OK", body.len(), "").as_bytes()) + .and_then(|()| stream.write_all(&body[..cut])), + Some(Answer::Silence(body)) => stream + .write_all(head("200 OK", body.len(), "").as_bytes()) + .map(|()| thread::sleep(Duration::from_secs(3))), + }; + } + + fn package(len: usize) -> Vec { + (0..len).map(|i| (i % 251) as u8).collect() + } + + fn download( + feed: &HttpFeed, + urls: &[String], + size: u64, + dest: &Path, + ) -> (Result<(), UpdateError>, Vec) { + let mut heard = Vec::new(); + let result = feed.download( + urls, + size, + dest, + &mut |bytes| heard.push(bytes), + &AtomicBool::new(false), + ); + (result, heard) + } + + #[test] + fn the_manifest_is_fetched_with_the_shellrs_user_agent() { + let server = Server::start(); + server.answer("/beta.json", Answer::Body(b"{}".to_vec())); + assert_eq!(server.feed().fetch(Channel::Beta), Ok(b"{}".to_vec())); + assert_eq!( + server.feed().fetch(Channel::Stable), + Err(UpdateError::Http(404)) + ); + let seen = server.seen(); + assert_eq!(seen[0].path, "/beta.json"); + assert!( + seen[0] + .user_agent + .as_deref() + .is_some_and(|agent| agent.starts_with("ShellRS/")), + "{seen:?}" + ); + } + + #[test] + fn a_download_resumes_from_its_part_file() { + let server = Server::start(); + let body = package(200_000); + server.answer("/p", Answer::Body(body.clone())); + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("package.zip"); + fs::write(part_path(&dest), &body[..150_000]).unwrap(); + + let (result, heard) = download(&server.feed(), &[server.url("/p")], 200_000, &dest); + assert_eq!(result, Ok(())); + assert_eq!(fs::read(&dest).unwrap(), body); + assert!(!part_path(&dest).exists()); + assert_eq!(heard.first(), Some(&150_000)); + assert_eq!(heard.last(), Some(&200_000)); + assert_eq!(server.seen()[0].range.as_deref(), Some("bytes=150000-")); + } + + #[test] + fn a_server_ignoring_range_starts_over() { + let server = Server::start(); + let body = package(50_000); + server.answer("/p", Answer::IgnoringRange(body.clone())); + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("package.zip"); + fs::write(part_path(&dest), &body[..10_000]).unwrap(); + + let (result, _) = download(&server.feed(), &[server.url("/p")], 50_000, &dest); + assert_eq!(result, Ok(())); + assert_eq!(fs::read(&dest).unwrap(), body); + } + + #[test] + fn the_next_url_is_tried_when_one_fails() { + let server = Server::start(); + let body = package(80_000); + server.answer("/broken", Answer::CutAfter(body.clone(), 30_000)); + server.answer("/mirror", Answer::Body(body.clone())); + server.answer("/busy", Answer::Status(503)); + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("package.zip"); + + let only_busy = download(&server.feed(), &[server.url("/busy")], 80_000, &dest); + assert_eq!(only_busy.0, Err(UpdateError::Http(503))); + + let urls = [ + server.url("/missing"), + server.url("/busy"), + server.url("/broken"), + server.url("/mirror"), + ]; + let (result, _) = download(&server.feed(), &urls, 80_000, &dest); + assert_eq!(result, Ok(())); + assert_eq!(fs::read(&dest).unwrap(), body); + // The mirror was asked only for what the broken address had not sent. + let seen = server.seen(); + assert_eq!(seen.last().unwrap().path, "/mirror"); + assert_eq!(seen.last().unwrap().range.as_deref(), Some("bytes=30000-")); + } + + #[test] + fn a_stalled_download_fails_instead_of_hanging() { + let server = Server::start(); + server.answer("/p", Answer::Silence(package(1000))); + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("package.zip"); + let (result, _) = download(&server.feed(), &[server.url("/p")], 1000, &dest); + assert_eq!(result, Err(UpdateError::Stalled)); + } + + #[test] + fn a_package_larger_than_promised_is_thrown_away() { + let server = Server::start(); + server.answer("/p", Answer::Body(package(2000))); + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("package.zip"); + let (result, _) = download(&server.feed(), &[server.url("/p")], 1000, &dest); + assert_eq!(result, Err(UpdateError::Corrupt)); + assert!(!part_path(&dest).exists()); + assert!(!dest.exists()); + } + + #[test] + fn cancelling_stops_the_download() { + let server = Server::start(); + server.answer("/p", Answer::Body(package(300_000))); + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("package.zip"); + let cancel = AtomicBool::new(false); + let result = server.feed().download( + &[server.url("/p")], + 300_000, + &dest, + &mut |bytes| { + if bytes > 0 { + cancel.store(true, Ordering::Relaxed); + } + }, + &cancel, + ); + assert_eq!(result, Err(UpdateError::Cancelled)); + assert!(!dest.exists()); + } + + #[test] + fn a_complete_part_file_needs_no_request() { + let server = Server::start(); + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join("package.zip"); + fs::write(part_path(&dest), package(10)).unwrap(); + let (result, _) = download(&server.feed(), &[server.url("/p")], 10, &dest); + assert_eq!(result, Ok(())); + assert!(server.seen().is_empty()); + } +} diff --git a/src/update/install.rs b/src/update/install.rs new file mode 100644 index 0000000..e65cc4a --- /dev/null +++ b/src/update/install.rs @@ -0,0 +1,419 @@ +//! Putting a new version in place. +//! +//! Who replaces ShellRS depends on how it was installed: a macOS bundle is +//! swapped whole, the Windows setup program updates its own install, an +//! AppImage is one file renamed over. Anything else (a development build, a +//! copy still on its disk image, a distribution package) still hears about +//! new versions but is sent to the download page. +//! +//! Staging does the slow part in the background right after the download +//! is verified; applying is renames or starting a process, quick enough for +//! the moment ShellRS restarts or quits. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use semver::Version; + +use super::error::UpdateError; +use super::manifest::Release; + +/// Where this ShellRS runs from, as far as updating goes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum InstallKind { + /// A macOS app bundle in a folder the user can write to. + MacBundle { + bundle: PathBuf, + }, + /// Installed by ShellRS's setup program, which also updates it. + WindowsSetup { + exe: PathBuf, + }, + /// An AppImage file in a folder the user can write to. + AppImage { + file: PathBuf, + }, + Unsupported(Unsupported), +} + +/// Why this copy of ShellRS cannot replace itself. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Unsupported { + /// Built by hand: no release replaces it. + DevelopmentBuild, + /// macOS runs it from a read-only copy because it was never moved out of + /// the folder it was downloaded to. + Translocated, + /// Still on the disk image it came on. + DiskImage, + /// The folder it is in cannot be written to. + ReadOnly(PathBuf), + /// macOS, not from an app bundle. + NotBundled, + /// Windows, not installed by the setup program. + NotInstalled, + /// Linux, not an AppImage: a distribution package or a build of its own. + NotAppImage, + /// No package of ShellRS for this platform. + Platform, +} + +impl Unsupported { + /// Why, for 设置 › 关于 and the update dialog. + pub fn reason(&self) -> String { + match self { + Self::DevelopmentBuild => "开发构建,不检查更新".into(), + Self::Translocated => { + "ShellRS 不在“应用程序”文件夹中,无法自动更新。请把它移到“应用程序”文件夹后重新打开" + .into() + } + Self::DiskImage => { + "ShellRS 正在从磁盘映像运行,无法自动更新。请先把它拖到“应用程序”文件夹".into() + } + Self::ReadOnly(folder) => { + format!("没有权限写入 {},无法自动更新", folder.display()) + } + Self::NotBundled => "ShellRS 不是从应用程序包运行的,无法自动更新".into(), + Self::NotInstalled => "这份 ShellRS 不是用安装程序安装的,无法自动更新".into(), + Self::NotAppImage => "这份 ShellRS 不是 AppImage,请用安装它的方式更新".into(), + Self::Platform => "ShellRS 没有为这个系统提供更新".into(), + } + } +} + +/// What this machine looks like, for [`detect`]. Separate from the real +/// file system so that every case can be tested anywhere. +pub struct Surroundings<'a> { + /// `std::env::consts::OS`. + pub os: &'a str, + /// The running executable. + pub exe: &'a Path, + /// macOS: the app bundle as the system sees it (`cx.app_path()`). + pub bundle: Option<&'a Path>, + /// Linux: `$APPIMAGE`, set by the AppImage runtime. + pub appimage: Option<&'a Path>, + pub writable: &'a dyn Fn(&Path) -> bool, + pub exists: &'a dyn Fn(&Path) -> bool, +} + +/// How this copy of ShellRS can be updated. +pub fn detect(at: &Surroundings) -> InstallKind { + match at.os { + "macos" => { + let Some(bundle) = at.bundle.filter(|bundle| { + bundle + .extension() + .is_some_and(|extension| extension == "app") + }) else { + return InstallKind::Unsupported(Unsupported::NotBundled); + }; + if bundle + .components() + .any(|part| part.as_os_str() == "AppTranslocation") + { + return InstallKind::Unsupported(Unsupported::Translocated); + } + let folder = bundle.parent().unwrap_or(Path::new("/")); + if !(at.writable)(folder) || !(at.writable)(bundle) { + return InstallKind::Unsupported(if bundle.starts_with("/Volumes") { + Unsupported::DiskImage + } else { + Unsupported::ReadOnly(folder.to_path_buf()) + }); + } + InstallKind::MacBundle { + bundle: bundle.to_path_buf(), + } + } + "windows" => { + let folder = at.exe.parent().unwrap_or(Path::new(".")); + if (at.exists)(&folder.join("unins000.exe")) { + InstallKind::WindowsSetup { + exe: at.exe.to_path_buf(), + } + } else { + InstallKind::Unsupported(Unsupported::NotInstalled) + } + } + "linux" => match at.appimage { + Some(file) => { + let folder = file.parent().unwrap_or(Path::new("/")); + if (at.writable)(folder) { + InstallKind::AppImage { + file: file.to_path_buf(), + } + } else { + InstallKind::Unsupported(Unsupported::ReadOnly(folder.to_path_buf())) + } + } + None => InstallKind::Unsupported(Unsupported::NotAppImage), + }, + _ => InstallKind::Unsupported(Unsupported::Platform), + } +} + +/// A verified package, unpacked or copied to where installing it is quick. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Staged { + pub version: Version, + pub path: PathBuf, +} + +/// What ShellRS does once the new version is in place. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Relaunch { + /// Restart from this path (`cx.set_restart_path` and `cx.restart`). + Restart(PathBuf), + /// Start this, which installs once ShellRS has quit, then quit. + Hand(Launch), + /// Nothing: ShellRS is quitting anyway. + Nothing, +} + +/// A program to start, prepared on the UI thread and started outside any +/// entity update: on Windows, creating a process can pump the message loop +/// and re-enter GPUI (gpui's own restart defers its spawn for this reason). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Launch { + pub program: PathBuf, + pub args: Vec, + pub envs: Vec<(OsString, OsString)>, +} + +impl Launch { + pub fn spawn(&self) -> std::io::Result<()> { + let mut command = std::process::Command::new(&self.program); + command.args(&self.args).envs(self.envs.iter().cloned()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + // No console window flashing up. + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } + command.spawn().map(drop) + } +} + +/// Installs updates the way this copy of ShellRS was installed. +pub trait Installer: Send + Sync { + fn kind(&self) -> &InstallKind; + + /// Get a verified package ready to install. Runs on an update worker. + fn stage(&self, package: &Path, release: &Release) -> Result; + + /// Put the staged version in place. Runs on the UI thread as ShellRS + /// restarts (`relaunch`) or quits, so it only renames or starts a + /// process. + fn apply(&self, staged: &Staged, relaunch: bool) -> Result; + + /// Remove what earlier updates left next to the installed copy. Called a + /// while after start, so a version that crashes right away leaves the + /// previous one's backup behind. + fn clean_up(&self); +} + +/// For copies that cannot replace themselves. +pub struct NoInstaller(InstallKind); + +impl NoInstaller { + pub fn new(reason: Unsupported) -> Self { + Self(InstallKind::Unsupported(reason)) + } +} + +impl Installer for NoInstaller { + fn kind(&self) -> &InstallKind { + &self.0 + } + + fn stage(&self, _: &Path, _: &Release) -> Result { + Err(UpdateError::Install("这份 ShellRS 无法自动更新".into())) + } + + fn apply(&self, _: &Staged, _: bool) -> Result { + Err(UpdateError::Install("这份 ShellRS 无法自动更新".into())) + } + + fn clean_up(&self) {} +} + +/// The installer for this copy of ShellRS. `bundle` is `cx.app_path()`. +pub fn system_installer(bundle: Option) -> Arc { + let exe = std::env::current_exe().unwrap_or_default(); + let appimage = std::env::var_os("APPIMAGE").map(PathBuf::from); + let kind = detect(&Surroundings { + os: std::env::consts::OS, + exe: &exe, + bundle: bundle.as_deref(), + appimage: appimage.as_deref(), + writable: &is_writable, + exists: &|path| path.exists(), + }); + match kind { + #[cfg(target_os = "macos")] + InstallKind::MacBundle { bundle } => Arc::new(super::install_macos::MacInstaller::new( + bundle, + Arc::new(super::install_macos::SystemTools), + )), + #[cfg(windows)] + InstallKind::WindowsSetup { exe } => { + Arc::new(super::install_windows::SetupInstaller::new(exe)) + } + #[cfg(target_os = "linux")] + InstallKind::AppImage { file } => { + Arc::new(super::install_linux::AppImageInstaller::new(file)) + } + InstallKind::Unsupported(reason) => Arc::new(NoInstaller::new(reason)), + #[allow(unreachable_patterns)] + _ => Arc::new(NoInstaller::new(Unsupported::Platform)), + } +} + +/// Whether ShellRS may create and rename things in `path`. +fn is_writable(path: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt as _; + let Ok(path) = std::ffi::CString::new(path.as_os_str().as_bytes()) else { + return false; + }; + // SAFETY: a valid C string; `access` only reads it. + unsafe { libc::access(path.as_ptr(), libc::W_OK) == 0 } + } + #[cfg(not(unix))] + { + std::fs::metadata(path).is_ok_and(|meta| !meta.permissions().readonly()) + } +} + +/// A sibling of `path` named `name`, for staging next to an installed copy +/// so that every rename stays on one volume. +#[cfg(any(target_os = "macos", target_os = "linux", test))] +pub(crate) fn sibling(path: &Path, name: &str) -> PathBuf { + path.with_file_name(name) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at<'a>(os: &'a str, exe: &'a Path) -> Surroundings<'a> { + Surroundings { + os, + exe, + bundle: None, + appimage: None, + writable: &|_| true, + exists: &|_| false, + } + } + + #[test] + fn an_app_in_applications_replaces_itself() { + let exe = Path::new("/Applications/ShellRS.app/Contents/MacOS/shellrs"); + let bundle = Path::new("/Applications/ShellRS.app"); + let kind = detect(&Surroundings { + bundle: Some(bundle), + ..at("macos", exe) + }); + assert_eq!( + kind, + InstallKind::MacBundle { + bundle: bundle.into() + } + ); + } + + #[test] + fn a_translocated_or_disk_image_app_asks_to_be_moved_first() { + let exe = Path::new("/x/ShellRS.app/Contents/MacOS/shellrs"); + let translocated = + Path::new("/private/var/folders/x/T/AppTranslocation/1234/d/ShellRS.app"); + assert_eq!( + detect(&Surroundings { + bundle: Some(translocated), + ..at("macos", exe) + }), + InstallKind::Unsupported(Unsupported::Translocated) + ); + let on_disk_image = Path::new("/Volumes/ShellRS/ShellRS.app"); + assert_eq!( + detect(&Surroundings { + bundle: Some(on_disk_image), + writable: &|_| false, + ..at("macos", exe) + }), + InstallKind::Unsupported(Unsupported::DiskImage) + ); + let theirs = Path::new("/Applications/ShellRS.app"); + assert_eq!( + detect(&Surroundings { + bundle: Some(theirs), + writable: &|path| path != Path::new("/Applications"), + ..at("macos", exe) + }), + InstallKind::Unsupported(Unsupported::ReadOnly("/Applications".into())) + ); + assert!( + Unsupported::Translocated + .reason() + .contains("“应用程序”文件夹") + ); + } + + #[test] + fn a_mac_build_outside_a_bundle_cannot_replace_itself() { + let exe = Path::new("/src/shellr/target/debug/shellrs"); + assert_eq!( + detect(&Surroundings { + bundle: Some(Path::new("/src/shellr/target/debug")), + ..at("macos", exe) + }), + InstallKind::Unsupported(Unsupported::NotBundled) + ); + } + + #[test] + fn only_a_copy_the_setup_program_installed_updates_on_windows() { + let exe = Path::new(r"C:\Users\me\AppData\Local\Programs\ShellRS\shellrs.exe"); + assert_eq!( + detect(&Surroundings { + exists: &|path| path.ends_with("unins000.exe"), + ..at("windows", exe) + }), + InstallKind::WindowsSetup { exe: exe.into() } + ); + assert_eq!( + detect(&at("windows", exe)), + InstallKind::Unsupported(Unsupported::NotInstalled) + ); + } + + #[test] + fn linux_outside_an_appimage_points_to_the_download_page() { + let exe = Path::new("/usr/bin/shellrs"); + assert_eq!( + detect(&at("linux", exe)), + InstallKind::Unsupported(Unsupported::NotAppImage) + ); + let file = Path::new("/home/me/Apps/ShellRS-x86_64.AppImage"); + let mounted = Path::new("/tmp/.mount_ShellRabc/usr/bin/shellrs"); + assert_eq!( + detect(&Surroundings { + appimage: Some(file), + ..at("linux", mounted) + }), + InstallKind::AppImage { file: file.into() } + ); + assert_eq!( + detect(&Surroundings { + appimage: Some(file), + writable: &|_| false, + ..at("linux", mounted) + }), + InstallKind::Unsupported(Unsupported::ReadOnly("/home/me/Apps".into())) + ); + } +} diff --git a/src/update/install_linux.rs b/src/update/install_linux.rs new file mode 100644 index 0000000..32a7dae --- /dev/null +++ b/src/update/install_linux.rs @@ -0,0 +1,127 @@ +//! Linux: an AppImage is one file, renamed over. +//! +//! The new AppImage is copied next to the running one first, so putting it +//! in place is one rename on one volume. The running copy keeps its mount +//! of the old file until it exits; the restart starts `$APPIMAGE`, not the +//! mounted executable, which disappears with the old process. + +use std::fs; +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; + +use super::error::UpdateError; +use super::install::{InstallKind, Installer, Relaunch, Staged, sibling}; +use super::manifest::Release; + +pub struct AppImageInstaller { + kind: InstallKind, + file: PathBuf, +} + +impl AppImageInstaller { + pub fn new(file: PathBuf) -> Self { + Self { + kind: InstallKind::AppImage { file: file.clone() }, + file, + } + } + + fn prefix(&self) -> String { + format!( + ".{}.update-", + self.file.file_name().unwrap_or_default().to_string_lossy() + ) + } +} + +impl Installer for AppImageInstaller { + fn kind(&self) -> &InstallKind { + &self.kind + } + + fn stage(&self, package: &Path, release: &Release) -> Result { + let staged = sibling(&self.file, &format!("{}{}", self.prefix(), release.version)); + fs::copy(package, &staged)?; + fs::set_permissions(&staged, fs::Permissions::from_mode(0o755))?; + Ok(Staged { + version: release.version.clone(), + path: staged, + }) + } + + fn apply(&self, staged: &Staged, relaunch: bool) -> Result { + fs::rename(&staged.path, &self.file) + .map_err(|error| UpdateError::Install(error.to_string()))?; + Ok(if relaunch { + Relaunch::Restart(self.file.clone()) + } else { + Relaunch::Nothing + }) + } + + fn clean_up(&self) { + let Some(folder) = self.file.parent() else { + return; + }; + let prefix = self.prefix(); + for entry in fs::read_dir(folder).into_iter().flatten().flatten() { + if entry.file_name().to_string_lossy().starts_with(&prefix) { + let _ = fs::remove_file(entry.path()); + } + } + } +} + +#[cfg(test)] +mod tests { + use semver::Version; + + use super::*; + use crate::update::manifest::Asset; + + #[test] + fn the_appimage_is_replaced_in_one_rename_and_stays_executable() { + let folder = tempfile::tempdir().unwrap(); + let file = folder.path().join("ShellRS-x86_64.AppImage"); + fs::write(&file, "old").unwrap(); + let package = folder.path().join("downloaded"); + fs::write(&package, "new").unwrap(); + let release = Release { + version: Version::new(0, 2, 0), + published_at: None, + notes: String::new(), + notes_url: None, + asset: Asset { + urls: Vec::new(), + size: 3, + sha256: String::new(), + }, + installer: None, + }; + let installer = AppImageInstaller::new(file.clone()); + let staged = installer.stage(&package, &release).unwrap(); + assert_eq!(fs::read_to_string(&file).unwrap(), "old"); + + assert_eq!( + installer.apply(&staged, true), + Ok(Relaunch::Restart(file.clone())) + ); + assert_eq!(fs::read_to_string(&file).unwrap(), "new"); + let mode = fs::metadata(&file).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o755); + + fs::write( + sibling(&file, ".ShellRS-x86_64.AppImage.update-0.1.9"), + "stale", + ) + .unwrap(); + installer.clean_up(); + let left: Vec<_> = fs::read_dir(folder.path()) + .unwrap() + .flatten() + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(left.len(), 2, "{left:?}"); + assert!(left.contains(&"ShellRS-x86_64.AppImage".to_string())); + } +} diff --git a/src/update/install_macos.rs b/src/update/install_macos.rs new file mode 100644 index 0000000..9cd3e8d --- /dev/null +++ b/src/update/install_macos.rs @@ -0,0 +1,477 @@ +//! macOS: the app bundle is replaced whole. +//! +//! The package is the stapled bundle zipped by `ditto`. It is unpacked +//! next to the installed bundle, into `.ShellRS.app.update-`, so +//! that putting it in place is two renames on one volume: the running +//! bundle moves aside to `.ShellRS.app.old-` and the new one takes its +//! name. The running process keeps its files open, and its assets are built +//! into the binary, so it carries on until the restart. The old bundle is +//! removed by the next version's clean-up. + +use std::ffi::OsStr; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; + +use super::build_info::APPLE_TEAM_ID; +use super::error::UpdateError; +use super::install::{InstallKind, Installer, Relaunch, Staged, sibling}; +use super::manifest::Release; + +/// The macOS tools staging relies on, behind a seam for the tests. +pub trait MacTools: Send + Sync { + /// Unpack a zip made by `ditto -c -k --keepParent` into `into`. + fn unpack(&self, zip: &Path, into: &Path) -> Result<(), UpdateError>; + + /// The bundle's `CFBundleShortVersionString`. + fn bundle_version(&self, bundle: &Path) -> Result; + + /// Whether the bundle's signature is intact and, given a team, made by + /// that team's Developer ID. + fn check_signature(&self, bundle: &Path, team: Option<&str>) -> Result<(), UpdateError>; +} + +/// `ditto`, `plutil` and `codesign`. +pub struct SystemTools; + +impl SystemTools { + fn run(program: &str, args: &[&OsStr]) -> Result { + let output = Command::new(program) + .args(args) + .output() + .map_err(|error| UpdateError::Install(format!("{program}:{error}")))?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(UpdateError::Install(format!( + "{program}:{}", + stderr.trim() + ))) + } + } +} + +impl MacTools for SystemTools { + fn unpack(&self, zip: &Path, into: &Path) -> Result<(), UpdateError> { + Self::run( + "/usr/bin/ditto", + &[ + "-x".as_ref(), + "-k".as_ref(), + zip.as_os_str(), + into.as_os_str(), + ], + ) + .map(drop) + } + + fn bundle_version(&self, bundle: &Path) -> Result { + let plist = bundle.join("Contents/Info.plist"); + Self::run( + "/usr/bin/plutil", + &[ + "-extract".as_ref(), + "CFBundleShortVersionString".as_ref(), + "raw".as_ref(), + "-o".as_ref(), + "-".as_ref(), + plist.as_os_str(), + ], + ) + } + + fn check_signature(&self, bundle: &Path, team: Option<&str>) -> Result<(), UpdateError> { + let requirement = team.map(|team| { + format!("-R=anchor apple generic and certificate leaf[subject.OU] = \"{team}\"") + }); + let mut args: Vec<&OsStr> = + vec!["--verify".as_ref(), "--deep".as_ref(), "--strict".as_ref()]; + if let Some(requirement) = &requirement { + args.push(requirement.as_ref()); + } + args.push(bundle.as_os_str()); + Self::run("/usr/bin/codesign", &args) + .map(drop) + .map_err(|_| UpdateError::Install("新版本的签名无效或不是 ShellRS 的开发者".into())) + } +} + +pub struct MacInstaller { + kind: InstallKind, + bundle: PathBuf, + tools: Arc, + /// The Developer ID team a new bundle must be signed by: the one this + /// release was signed by. + team: Option, +} + +impl MacInstaller { + pub fn new(bundle: PathBuf, tools: Arc) -> Self { + Self { + kind: InstallKind::MacBundle { + bundle: bundle.clone(), + }, + bundle, + tools, + team: APPLE_TEAM_ID.map(String::from), + } + } + + pub fn with_team(mut self, team: Option<&str>) -> Self { + self.team = team.map(String::from); + self + } + + /// `ShellRS.app`, whatever the user named it. + fn name(&self) -> String { + self.bundle + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned() + } + + fn staging(&self, release: &Release) -> PathBuf { + sibling( + &self.bundle, + &format!(".{}.update-{}", self.name(), release.version), + ) + } + + fn unpack( + &self, + package: &Path, + release: &Release, + staging: &Path, + ) -> Result { + self.tools.unpack(package, staging)?; + let app = fs::read_dir(staging)? + .flatten() + .map(|entry| entry.path()) + .find(|path| path.extension().is_some_and(|extension| extension == "app")) + .ok_or_else(|| UpdateError::Install("更新包里没有应用程序".into()))?; + if self.tools.bundle_version(&app)? != release.version.to_string() { + return Err(UpdateError::Install("更新包的版本与更新清单不符".into())); + } + self.tools.check_signature(&app, self.team.as_deref())?; + Ok(app) + } +} + +impl Installer for MacInstaller { + fn kind(&self) -> &InstallKind { + &self.kind + } + + fn stage(&self, package: &Path, release: &Release) -> Result { + let staging = self.staging(release); + if staging.exists() { + fs::remove_dir_all(&staging)?; + } + fs::create_dir_all(&staging)?; + match self.unpack(package, release, &staging) { + Ok(path) => Ok(Staged { + version: release.version.clone(), + path, + }), + Err(error) => { + let _ = fs::remove_dir_all(&staging); + Err(error) + } + } + } + + fn apply(&self, staged: &Staged, relaunch: bool) -> Result { + let install = |error: std::io::Error| UpdateError::Install(error.to_string()); + let aside = sibling( + &self.bundle, + &format!(".{}.old-{}", self.name(), uuid::Uuid::new_v4().simple()), + ); + fs::rename(&self.bundle, &aside).map_err(install)?; + if let Err(error) = fs::rename(&staged.path, &self.bundle) { + let _ = fs::rename(&aside, &self.bundle); + return Err(install(error)); + } + if let Some(staging) = staged.path.parent() { + let _ = fs::remove_dir(staging); + } + Ok(if relaunch { + Relaunch::Restart(self.bundle.clone()) + } else { + Relaunch::Nothing + }) + } + + fn clean_up(&self) { + let Some(folder) = self.bundle.parent() else { + return; + }; + let (old, update) = ( + format!(".{}.old-", self.name()), + format!(".{}.update-", self.name()), + ); + for entry in fs::read_dir(folder).into_iter().flatten().flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if name.starts_with(&old) || name.starts_with(&update) { + let _ = fs::remove_dir_all(entry.path()); + } + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use semver::Version; + + use super::*; + use crate::update::manifest::Asset; + + /// Unpacks a fake bundle whose version and team are whatever the test + /// says, and checks the team against the one asked for. + struct FakeTools { + version: String, + team: Option, + asked_for: Mutex>>, + } + + impl FakeTools { + fn new(version: &str, team: Option<&str>) -> Arc { + Arc::new(Self { + version: version.into(), + team: team.map(Into::into), + asked_for: Mutex::default(), + }) + } + } + + impl MacTools for FakeTools { + fn unpack(&self, _: &Path, into: &Path) -> Result<(), UpdateError> { + let contents = into.join("ShellRS.app/Contents"); + fs::create_dir_all(&contents)?; + fs::write(contents.join("version"), &self.version)?; + Ok(()) + } + + fn bundle_version(&self, bundle: &Path) -> Result { + Ok(fs::read_to_string(bundle.join("Contents/version"))?) + } + + fn check_signature(&self, _: &Path, team: Option<&str>) -> Result<(), UpdateError> { + self.asked_for.lock().unwrap().push(team.map(Into::into)); + match (team, &self.team) { + (Some(wanted), Some(signed)) if wanted != signed => { + Err(UpdateError::Install("another team".into())) + } + _ => Ok(()), + } + } + } + + fn release(version: &str) -> Release { + Release { + version: Version::parse(version).unwrap(), + published_at: None, + notes: String::new(), + notes_url: None, + asset: Asset { + urls: Vec::new(), + size: 0, + sha256: String::new(), + }, + installer: None, + } + } + + /// `/Applications`-like folder holding a running 0.1.0 bundle. + fn installed() -> (tempfile::TempDir, PathBuf) { + let folder = tempfile::tempdir().unwrap(); + let bundle = folder.path().join("ShellRS.app"); + fs::create_dir_all(bundle.join("Contents")).unwrap(); + fs::write(bundle.join("Contents/version"), "0.1.0").unwrap(); + (folder, bundle) + } + + fn version_at(bundle: &Path) -> String { + fs::read_to_string(bundle.join("Contents/version")).unwrap() + } + + fn names_in(folder: &Path) -> Vec { + let mut names: Vec = fs::read_dir(folder) + .unwrap() + .flatten() + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + names + } + + #[test] + fn a_staged_bundle_replaces_the_running_one_and_the_old_one_waits_for_cleanup() { + let (folder, bundle) = installed(); + let installer = MacInstaller::new(bundle.clone(), FakeTools::new("0.2.0", None)); + let staged = installer + .stage(Path::new("package.zip"), &release("0.2.0")) + .unwrap(); + assert!( + staged + .path + .starts_with(folder.path().join(".ShellRS.app.update-0.2.0")) + ); + assert_eq!( + version_at(&bundle), + "0.1.0", + "staging leaves the running copy alone" + ); + + assert_eq!( + installer.apply(&staged, true), + Ok(Relaunch::Restart(bundle.clone())) + ); + assert_eq!(version_at(&bundle), "0.2.0"); + let names = names_in(folder.path()); + assert_eq!(names.len(), 2, "{names:?}"); + assert!(names[0].starts_with(".ShellRS.app.old-"), "{names:?}"); + + installer.clean_up(); + assert_eq!(names_in(folder.path()), ["ShellRS.app"]); + } + + #[test] + fn a_failed_swap_puts_the_old_bundle_back() { + let (folder, bundle) = installed(); + let installer = MacInstaller::new(bundle.clone(), FakeTools::new("0.2.0", None)); + let staged = installer + .stage(Path::new("package.zip"), &release("0.2.0")) + .unwrap(); + fs::remove_dir_all(&staged.path).unwrap(); + + assert!(matches!( + installer.apply(&staged, true), + Err(UpdateError::Install(_)) + )); + assert_eq!(version_at(&bundle), "0.1.0"); + installer.clean_up(); + assert_eq!(names_in(folder.path()), ["ShellRS.app"]); + } + + #[test] + fn installing_on_quit_does_not_restart() { + let (_folder, bundle) = installed(); + let installer = MacInstaller::new(bundle.clone(), FakeTools::new("0.2.0", None)); + let staged = installer + .stage(Path::new("package.zip"), &release("0.2.0")) + .unwrap(); + assert_eq!(installer.apply(&staged, false), Ok(Relaunch::Nothing)); + assert_eq!(version_at(&bundle), "0.2.0"); + } + + #[test] + fn a_package_of_another_version_is_refused() { + let (folder, bundle) = installed(); + let installer = MacInstaller::new(bundle, FakeTools::new("0.1.5", None)); + assert!(matches!( + installer.stage(Path::new("package.zip"), &release("0.2.0")), + Err(UpdateError::Install(_)) + )); + assert_eq!( + names_in(folder.path()), + ["ShellRS.app"], + "no staging left behind" + ); + } + + #[test] + fn a_bundle_signed_by_another_team_is_refused() { + let (folder, bundle) = installed(); + let tools = FakeTools::new("0.2.0", Some("SOMEONEELSE")); + let installer = MacInstaller::new(bundle.clone(), tools.clone()).with_team(Some("SHELLRS")); + assert!(matches!( + installer.stage(Path::new("package.zip"), &release("0.2.0")), + Err(UpdateError::Install(_)) + )); + assert_eq!( + *tools.asked_for.lock().unwrap(), + [Some("SHELLRS".to_string())] + ); + assert_eq!(names_in(folder.path()), ["ShellRS.app"]); + + let ours = FakeTools::new("0.2.0", Some("SHELLRS")); + let installer = MacInstaller::new(bundle, ours).with_team(Some("SHELLRS")); + assert!( + installer + .stage(Path::new("package.zip"), &release("0.2.0")) + .is_ok() + ); + } + + /// The real `ditto`, `plutil` and `codesign` on a small bundle signed + /// ad hoc: unpacked, checked and swapped in like a release. + #[cfg(target_os = "macos")] + #[test] + fn the_system_tools_unpack_check_and_swap_a_real_bundle() { + let work = tempfile::tempdir().unwrap(); + let bundle_at = |folder: &Path, version: &str| { + let app = folder.join("ShellRS.app"); + fs::create_dir_all(app.join("Contents/MacOS")).unwrap(); + fs::copy("/usr/bin/true", app.join("Contents/MacOS/shellrs")).unwrap(); + fs::write( + app.join("Contents/Info.plist"), + format!( + r#" + + +CFBundleExecutableshellrs +CFBundleIdentifiercom.shellrs.test +CFBundlePackageTypeAPPL +CFBundleShortVersionString{version} +"# + ), + ) + .unwrap(); + let signed = Command::new("/usr/bin/codesign") + .args(["--force", "--sign", "-"]) + .arg(&app) + .output() + .unwrap(); + assert!(signed.status.success(), "{signed:?}"); + app + }; + let installed = work.path().join("Applications"); + let bundle = bundle_at(&installed, "0.1.0"); + let built = work.path().join("built"); + let new = bundle_at(&built, "0.2.0"); + let package = work.path().join("ShellRS-0.2.0-macos-universal.app.zip"); + let zipped = Command::new("/usr/bin/ditto") + .args(["-c", "-k", "--keepParent"]) + .arg(&new) + .arg(&package) + .output() + .unwrap(); + assert!(zipped.status.success(), "{zipped:?}"); + + let installer = MacInstaller::new(bundle.clone(), Arc::new(SystemTools)).with_team(None); + assert!(matches!( + installer.stage(&package, &release("0.3.0")), + Err(UpdateError::Install(_)) + )); + let staged = installer.stage(&package, &release("0.2.0")).unwrap(); + assert_eq!( + installer.apply(&staged, true), + Ok(Relaunch::Restart(bundle.clone())) + ); + assert_eq!(SystemTools.bundle_version(&bundle).unwrap(), "0.2.0"); + assert_eq!(SystemTools.check_signature(&bundle, None), Ok(())); + // Signed ad hoc, so by no team. + assert!( + SystemTools + .check_signature(&bundle, Some("SHELLRS")) + .is_err() + ); + installer.clean_up(); + assert_eq!(names_in(&installed), ["ShellRS.app"]); + } +} diff --git a/src/update/install_windows.rs b/src/update/install_windows.rs new file mode 100644 index 0000000..f168d4e --- /dev/null +++ b/src/update/install_windows.rs @@ -0,0 +1,159 @@ +//! Windows: the setup program updates its own install. +//! +//! ShellRS is installed per user by an Inno Setup program, which owns the +//! files, the shortcuts and the entry in 应用和功能. An update runs the new +//! version's setup silently, the same way the person ran the first one. A +//! running program cannot be overwritten, so a hidden PowerShell waits for +//! ShellRS to exit, runs the setup, and starts ShellRS again when asked to. +//! When the setup fails it rolls back, and the restart brings up the old +//! version, which then says the update did not finish. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +use super::error::UpdateError; +use super::install::{InstallKind, Installer, Launch, Relaunch, Staged}; +use super::manifest::Release; + +/// Waits for ShellRS to exit, runs the setup, then starts ShellRS again if +/// asked. Values come in through the environment, not the script's text, +/// so no path needs quoting. +const WAITER: &str = r#"$ErrorActionPreference = 'SilentlyContinue' +$waitFor = [int]$env:SHELLRS_UPDATE_PID +while (Get-Process -Id $waitFor) { Start-Sleep -Milliseconds 100 } +Start-Process -FilePath $env:SHELLRS_UPDATE_SETUP -ArgumentList '/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART','/SP-' -Wait +if ($env:SHELLRS_UPDATE_RELAUNCH -eq '1') { Start-Process -FilePath $env:SHELLRS_UPDATE_APP } +"#; + +pub struct SetupInstaller { + kind: InstallKind, + exe: PathBuf, +} + +impl SetupInstaller { + pub fn new(exe: PathBuf) -> Self { + Self { + kind: InstallKind::WindowsSetup { exe: exe.clone() }, + exe, + } + } +} + +/// The hidden PowerShell that installs `setup` once process `pid` is gone. +pub fn waiter(pid: u32, setup: &Path, app: &Path, relaunch: bool) -> Launch { + let root = std::env::var_os("SystemRoot").unwrap_or_else(|| OsString::from(r"C:\Windows")); + let program = PathBuf::from(root).join(r"System32\WindowsPowerShell\v1.0\powershell.exe"); + let env = |name: &str, value: OsString| (OsString::from(name), value); + Launch { + program, + args: [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-WindowStyle", + "Hidden", + "-Command", + WAITER, + ] + .map(OsString::from) + .to_vec(), + envs: vec![ + env("SHELLRS_UPDATE_PID", pid.to_string().into()), + env("SHELLRS_UPDATE_SETUP", setup.as_os_str().to_owned()), + env("SHELLRS_UPDATE_APP", app.as_os_str().to_owned()), + env( + "SHELLRS_UPDATE_RELAUNCH", + if relaunch { "1" } else { "0" }.into(), + ), + ], + } +} + +impl Installer for SetupInstaller { + fn kind(&self) -> &InstallKind { + &self.kind + } + + /// The verified setup program is run as it is. + fn stage(&self, package: &Path, release: &Release) -> Result { + Ok(Staged { + version: release.version.clone(), + path: package.to_path_buf(), + }) + } + + fn apply(&self, staged: &Staged, relaunch: bool) -> Result { + if !staged.path.is_file() { + return Err(UpdateError::Install("找不到下载的安装程序".into())); + } + Ok(Relaunch::Hand(waiter( + std::process::id(), + &staged.path, + &self.exe, + relaunch, + ))) + } + + /// The setup program removes what the previous version left. + fn clean_up(&self) {} +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_windows_waiter_runs_the_installer_then_relaunches() { + let setup = Path::new(r"C:\Users\me\AppData\Local\shellrs\updates\0.2.0\setup.exe"); + let app = Path::new(r"C:\Users\me\AppData\Local\Programs\ShellRS\shellrs.exe"); + let launch = waiter(4242, setup, app, true); + // A string compare: on the Mac running the tests, backslashes are + // not separators. + let program = launch.program.to_string_lossy().replace('/', "\\"); + assert!( + program.ends_with(r"\System32\WindowsPowerShell\v1.0\powershell.exe"), + "{program}" + ); + assert_eq!( + launch.args.last().map(|script| script.to_str().unwrap()), + Some(WAITER) + ); + let env = |name: &str| { + launch + .envs + .iter() + .find(|(key, _)| key == name) + .map(|(_, value)| value.clone()) + }; + assert_eq!(env("SHELLRS_UPDATE_PID"), Some("4242".into())); + assert_eq!(env("SHELLRS_UPDATE_SETUP"), Some(setup.as_os_str().into())); + assert_eq!(env("SHELLRS_UPDATE_APP"), Some(app.as_os_str().into())); + assert_eq!(env("SHELLRS_UPDATE_RELAUNCH"), Some("1".into())); + assert_eq!( + waiter(1, setup, app, false) + .envs + .iter() + .find(|(key, _)| key == "SHELLRS_UPDATE_RELAUNCH") + .map(|(_, value)| value.clone()), + Some("0".into()) + ); + // Silent, and without the setup's own 继续吗? question. + for switch in ["/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/SP-"] { + assert!(WAITER.contains(switch), "{switch}"); + } + } + + #[test] + fn a_missing_setup_is_not_handed_over() { + let installer = SetupInstaller::new(PathBuf::from(r"C:\ShellRS\shellrs.exe")); + let staged = Staged { + version: semver::Version::new(0, 2, 0), + path: PathBuf::from("/nowhere/setup.exe"), + }; + assert!(matches!( + installer.apply(&staged, true), + Err(UpdateError::Install(_)) + )); + } +} diff --git a/src/update/manifest.rs b/src/update/manifest.rs new file mode 100644 index 0000000..8094297 --- /dev/null +++ b/src/update/manifest.rs @@ -0,0 +1,297 @@ +//! The release manifest: what the newest version of a channel is and where +//! its packages are. +//! +//! The file on the server is an [`Envelope`]: the manifest's JSON as a +//! string next to its minisign signature. One file means one request and +//! no moment where a cached signature belongs to the previous manifest. +//! `verify::open_envelope` checks the signature before anything here reads +//! the manifest. +//! +//! Every ShellRS ever shipped reads this format, so fields are only ever +//! added. Unknown fields are ignored; a `schema` newer than [`SCHEMA`] means +//! the manifest needs a newer ShellRS than this one, which then points at the +//! download page instead of updating itself. + +use std::collections::BTreeMap; + +use semver::Version; +use serde::Deserialize; + +use super::build_info::Channel; + +/// The newest manifest format this build reads. +pub const SCHEMA: u32 = 1; + +/// The file as served: the manifest's text and its signature. +#[derive(Debug, Deserialize)] +pub struct Envelope { + pub manifest: String, + pub signature: String, +} + +#[derive(Clone, Debug, PartialEq, Deserialize)] +pub struct Manifest { + pub schema: u32, + pub channel: Channel, + pub version: Version, + /// RFC 3339, e.g. `2026-10-20T08:00:00Z`. + #[serde(default)] + pub published_at: Option, + /// The oldest version that may update itself to this one. Older ones are + /// sent to the download page: for when installing changes in a way an + /// old updater cannot follow. + #[serde(default)] + pub minimum_version: Option, + /// The share of machines offered this release, 0 to 1. Each machine + /// draws its own number once and keeps it; nothing is reported back. + #[serde(default = "everyone")] + pub rollout: f64, + /// What changed, in Markdown. + #[serde(default)] + pub notes: String, + #[serde(default)] + pub notes_url: Option, + /// The package each platform updates itself with, by platform key. + #[serde(default)] + pub assets: BTreeMap, + /// What a person downloads to install by hand, by platform key. + #[serde(default)] + pub installers: BTreeMap, +} + +fn everyone() -> f64 { + 1.0 +} + +/// A package: where to get it and how to recognise it. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +pub struct Asset { + /// Tried in order, so a mirror can follow the main address. + pub urls: Vec, + pub size: u64, + /// Lowercase hex. + pub sha256: String, +} + +/// A newer version this machine can move to. +#[derive(Clone, Debug, PartialEq)] +pub struct Release { + pub version: Version, + pub published_at: Option, + pub notes: String, + pub notes_url: Option, + pub asset: Asset, + pub installer: Option, +} + +impl Release { + /// `2026-10-20`, from the publication time. + pub fn published_on(&self) -> Option<&str> { + self.published_at.as_deref().and_then(|at| at.get(..10)) + } +} + +/// What a manifest means for this machine. +#[derive(Clone, Debug, PartialEq)] +pub enum Offer { + /// Nothing newer for this platform, or not yet for this machine. + UpToDate, + /// A newer version to download and install. + Update(Release), + /// A newer version that this build cannot install itself; the person + /// has to download it. Carries the installer's address when there is one. + Manual { + version: Version, + installer: Option, + }, +} + +/// The schema of a manifest, read before the rest so that a format this +/// build does not know is recognised as such rather than as a broken file. +#[derive(Deserialize)] +struct SchemaOnly { + schema: u32, +} + +/// Read a manifest's text. `Ok(None)` is a format newer than this build. +pub fn parse(text: &str) -> Result, serde_json::Error> { + let SchemaOnly { schema } = serde_json::from_str(text)?; + if schema > SCHEMA { + return Ok(None); + } + serde_json::from_str(text).map(Some) +} + +impl Manifest { + /// What this manifest offers a machine running `current` on `platform`. + /// `draw` is the machine's own number in `0..1` for staged rollouts. + pub fn offer(&self, current: &Version, platform: &str, draw: f64) -> Offer { + if self.version <= *current || draw >= self.rollout { + return Offer::UpToDate; + } + let installer = self.installers.get(platform).cloned(); + let too_old = self + .minimum_version + .as_ref() + .is_some_and(|minimum| current < minimum); + match self.assets.get(platform) { + Some(asset) if !too_old => Offer::Update(Release { + version: self.version.clone(), + published_at: self.published_at.clone(), + notes: self.notes.clone(), + notes_url: self.notes_url.clone(), + asset: asset.clone(), + installer, + }), + Some(_) => Offer::Manual { + version: self.version.clone(), + installer, + }, + None => Offer::UpToDate, + } + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + pub(crate) fn manifest_text(version: &str) -> String { + format!( + r####"{{ + "schema": 1, "channel": "stable", "version": "{version}", + "published_at": "2026-10-20T08:00:00Z", + "notes": "### 新增\n- 在线升级", + "assets": {{ + "macos-aarch64": {{ "urls": ["https://dl.shellrs.com/a.zip"], "size": 3, "sha256": "ab" }}, + "linux-x86_64": {{ "urls": ["https://dl.shellrs.com/b.AppImage"], "size": 4, "sha256": "cd" }} + }}, + "installers": {{ "macos-aarch64": "https://dl.shellrs.com/a.dmg" }}, + "something_added_later": {{ "ignored": true }} + }}"#### + ) + } + + fn manifest(version: &str) -> Manifest { + parse(&manifest_text(version)).unwrap().unwrap() + } + + fn v(text: &str) -> Version { + Version::parse(text).unwrap() + } + + #[test] + fn a_newer_version_is_offered_and_the_same_or_older_is_not() { + let manifest = manifest("0.2.0"); + let Offer::Update(release) = manifest.offer(&v("0.1.0"), "macos-aarch64", 0.5) else { + panic!("an update for 0.1.0"); + }; + assert_eq!(release.version, v("0.2.0")); + assert_eq!(release.asset.urls, ["https://dl.shellrs.com/a.zip"]); + assert_eq!( + release.installer.as_deref(), + Some("https://dl.shellrs.com/a.dmg") + ); + assert_eq!(release.published_on(), Some("2026-10-20")); + assert_eq!( + manifest.offer(&v("0.2.0"), "macos-aarch64", 0.5), + Offer::UpToDate + ); + assert_eq!( + manifest.offer(&v("0.3.0"), "macos-aarch64", 0.5), + Offer::UpToDate + ); + } + + #[test] + fn a_prerelease_is_older_than_its_release() { + let manifest = manifest("0.2.0"); + assert!(matches!( + manifest.offer(&v("0.2.0-beta.2"), "macos-aarch64", 0.5), + Offer::Update(_) + )); + let beta = manifest_text("0.2.0-beta.3"); + let beta = parse(&beta).unwrap().unwrap(); + assert!(matches!( + beta.offer(&v("0.2.0-beta.2"), "linux-x86_64", 0.5), + Offer::Update(_) + )); + assert_eq!( + beta.offer(&v("0.2.0"), "linux-x86_64", 0.5), + Offer::UpToDate + ); + } + + #[test] + fn a_manifest_without_this_platform_offers_nothing() { + assert_eq!( + manifest("0.2.0").offer(&v("0.1.0"), "windows-x86_64", 0.5), + Offer::UpToDate + ); + } + + #[test] + fn a_draw_above_the_rollout_waits() { + let mut manifest = manifest("0.2.0"); + manifest.rollout = 0.25; + assert!(matches!( + manifest.offer(&v("0.1.0"), "macos-aarch64", 0.1), + Offer::Update(_) + )); + assert_eq!( + manifest.offer(&v("0.1.0"), "macos-aarch64", 0.25), + Offer::UpToDate + ); + assert_eq!( + manifest.offer(&v("0.1.0"), "macos-aarch64", 0.9), + Offer::UpToDate + ); + } + + #[test] + fn a_version_below_the_minimum_is_sent_to_the_download_page() { + let mut manifest = manifest("0.5.0"); + manifest.minimum_version = Some(v("0.3.0")); + assert_eq!( + manifest.offer(&v("0.2.0"), "macos-aarch64", 0.5), + Offer::Manual { + version: v("0.5.0"), + installer: Some("https://dl.shellrs.com/a.dmg".into()), + } + ); + assert!(matches!( + manifest.offer(&v("0.3.0"), "macos-aarch64", 0.5), + Offer::Update(_) + )); + } + + #[test] + fn a_manifest_with_unknown_fields_still_parses() { + let manifest = manifest("0.2.0"); + assert_eq!(manifest.rollout, 1.0); + assert_eq!(manifest.channel, Channel::Stable); + } + + #[test] + fn a_manifest_from_a_newer_format_is_recognised_as_such() { + let text = r#"{ "schema": 2, "whatever": "changed shape" }"#; + assert_eq!(parse(text).unwrap(), None); + assert!(parse(r#"{ "schema": 1 }"#).is_err()); + assert!(parse("not json").is_err()); + } + + #[test] + fn the_example_manifest_parses() { + let text = include_str!("../../packaging/manifest.example.json"); + let manifest = parse(text).unwrap().expect("schema 1"); + for platform in [ + "macos-aarch64", + "macos-x86_64", + "windows-x86_64", + "linux-x86_64", + ] { + assert!(manifest.assets.contains_key(platform), "{platform}"); + assert!(manifest.installers.contains_key(platform), "{platform}"); + } + } +} diff --git a/src/update/mod.rs b/src/update/mod.rs new file mode 100644 index 0000000..b584033 --- /dev/null +++ b/src/update/mod.rs @@ -0,0 +1,39 @@ +//! 在线升级:检查、下载、校验并安装新版本。 +//! +//! The manifest and packages come from `dl.shellrs.com` (`feed`), are +//! believed only after `verify`, and `install` puts the new version in +//! place the way each platform's package wants. Nothing here knows about +//! the window: the workspace wires the updater to the title bar, the +//! settings page and the restart. + +pub mod build_info; +pub mod error; +pub mod feed; +pub mod install; +// Each platform's installer also builds in the tests elsewhere, as far as +// its system calls allow, so that a Mac runs them all. +#[cfg(any(target_os = "linux", all(test, unix)))] +pub mod install_linux; +#[cfg(any(target_os = "macos", test))] +pub mod install_macos; +#[cfg(any(windows, test))] +pub mod install_windows; +pub mod manifest; +pub mod platform; +pub mod status; +pub mod update_dialog; +pub mod updater; +pub mod verify; + +pub use build_info::Channel; +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 update_dialog::{ImpactCounter, open_update_dialog}; +pub use updater::{ + ManualUpdate, Phase, Stage, UpdateServices, UpdateSnapshot, Updater, UpdaterEvent, + start_handed_over, +}; +pub use verify::{Opened, TrustedKeys}; diff --git a/src/update/platform.rs b/src/update/platform.rs new file mode 100644 index 0000000..75cef27 --- /dev/null +++ b/src/update/platform.rs @@ -0,0 +1,65 @@ +//! Which package of a release fits this machine. +//! +//! The keys are part of the manifest format, so they never change. A +//! universal macOS build runs its native half, so an Apple Silicon Mac asks +//! for `macos-aarch64` even though both keys point at the same package; an +//! Intel-only build under Rosetta would ask for `macos-x86_64` and keep +//! getting Intel builds, which is why macOS ships universal. + +/// The manifest's key for this build's platform. +pub fn platform_key() -> &'static str { + if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + "macos-aarch64" + } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) { + "macos-x86_64" + } else if cfg!(all(windows, target_arch = "x86_64")) { + "windows-x86_64" + } else if cfg!(all(target_os = "linux", target_arch = "x86_64")) { + "linux-x86_64" + } else { + "unsupported" + } +} + +/// The platform as 设置 › 关于 names it. +pub fn platform_label() -> &'static str { + match platform_key() { + "macos-aarch64" => "macOS · Apple Silicon", + "macos-x86_64" => "macOS · Intel", + "windows-x86_64" => "Windows · x64", + "linux-x86_64" => "Linux · x64", + _ => std::env::consts::OS, + } +} + +/// `ShellRS/0.2.0 (macos; aarch64)`: all the update server learns about +/// the machine asking. +pub fn user_agent(version: &str) -> String { + format!( + "ShellRS/{version} ({}; {})", + std::env::consts::OS, + std::env::consts::ARCH + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_user_agent_names_the_version_system_and_architecture() { + let agent = user_agent("1.2.3"); + assert!(agent.starts_with("ShellRS/1.2.3 ("), "{agent}"); + assert!(agent.contains(std::env::consts::OS), "{agent}"); + assert!( + agent.ends_with(&format!("{})", std::env::consts::ARCH)), + "{agent}" + ); + } + + #[test] + fn this_machine_has_a_package() { + assert_ne!(platform_key(), "unsupported"); + assert!(!platform_label().is_empty()); + } +} diff --git a/src/update/status.rs b/src/update/status.rs new file mode 100644 index 0000000..ba3b7bc --- /dev/null +++ b/src/update/status.rs @@ -0,0 +1,337 @@ +//! How the updater's state reads: the line on 设置 › 关于, the title bar +//! button, which buttons apply, and what a restart interrupts. Pure, so +//! every wording is tested without a window. + +use super::updater::{Phase, Stage, UpdateSnapshot}; + +/// The title bar's update button. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct UpdateBadge { + /// Its tooltip and accessible name. + pub label: String, + /// Something went wrong, rather than something being ready. + pub trouble: bool, +} + +/// The colour a status line is drawn in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tone { + Plain, + Success, + Warning, + Danger, +} + +impl UpdateSnapshot { + /// The version on offer, found or needing the download page. + pub fn offered_version(&self) -> Option { + self.release + .as_ref() + .map(|release| release.version.to_string()) + .or_else(|| { + self.manual + .as_ref() + .map(|manual| manual.version.to_string()) + }) + } + + /// Whether the version on offer has to be downloaded by hand. + pub fn needs_download_page(&self) -> bool { + self.manual.is_some() || (self.release.is_some() && self.unsupported.is_some()) + } + + /// 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()), + 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::Available if self.manual.is_some() => ( + format!("新版本 {offered} 需要从官网下载安装"), + Tone::Warning, + ), + Phase::Available => match &self.unsupported { + Some(reason) => ( + format!("发现新版本 {offered}。{}", reason.reason()), + Tone::Warning, + ), + None => (format!("发现新版本 {offered}"), Tone::Plain), + }, + Phase::Downloading { done, total } => ( + format!("正在下载 {offered} · {}%", percent(*done, *total)), + Tone::Plain, + ), + Phase::Verifying => (format!("正在校验 {offered}…"), Tone::Plain), + Phase::Ready => ( + format!("{offered} 已就绪,退出或重启 ShellRS 时安装"), + Tone::Success, + ), + 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}"), + }, + Tone::Danger, + ), + } + } + + /// 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. + pub fn badge(&self) -> Option { + let offered = self.offered_version()?; + let (label, trouble) = match &self.phase { + Phase::Ready => (format!("新版本 {offered} 已就绪"), false), + Phase::Available if self.needs_download_page() => { + (format!("新版本 {offered} 可用"), false) + } + Phase::Failed { + stage: Stage::Download, + .. + } if self.download_failures >= super::updater::FAILURES_BEFORE_BADGE => { + (format!("新版本 {offered} 下载失败"), true) + } + Phase::Failed { + stage: Stage::Install, + .. + } => (format!("新版本 {offered} 安装失败"), true), + _ => return None, + }; + Some(UpdateBadge { label, trouble }) + } + + /// 检查更新 applies; `false` while something is already under way. + pub fn can_check(&self) -> bool { + matches!( + self.phase, + Phase::Idle | Phase::UpToDate | Phase::Available | Phase::Failed { .. } + ) + } + + /// 下载 (or 重试) applies: a version this copy can install is waiting. + pub fn can_download(&self) -> bool { + self.release.is_some() + && self.unsupported.is_none() + && matches!( + self.phase, + Phase::Available + | Phase::Failed { + stage: Stage::Download | Stage::Install, + .. + } + ) + } +} + +/// `45` for 45 %. An empty total counts as done. +pub fn percent(done: u64, total: u64) -> u64 { + (done.min(total).saturating_mul(100)) + .checked_div(total) + .unwrap_or(100) +} + +/// What a restart would interrupt, counted by the workspace. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct RestartImpact { + pub remote_terminals: usize, + pub sftp_tabs: usize, + pub local_terminals: usize, + pub transfers: usize, + pub forwards: usize, +} + +/// 「重启会关闭 2 个远程终端和 1 个 SFTP 标签,停止 1 个传输(保留续传进度)。」, +/// or nothing when a restart interrupts nothing. +pub fn restart_note(impact: RestartImpact) -> Option { + let closes = join(&[ + (impact.remote_terminals, "个远程终端"), + (impact.sftp_tabs, "个 SFTP 标签"), + (impact.local_terminals, "个本地终端"), + ]); + let stops = join(&[ + (impact.transfers, "个传输(保留续传进度)"), + (impact.forwards, "条端口转发"), + ]); + let note = match (closes, stops) { + (None, None) => return None, + (Some(closes), None) => format!("重启会关闭 {closes}。"), + (None, Some(stops)) => format!("重启会停止 {stops}。"), + (Some(closes), Some(stops)) => format!("重启会关闭 {closes},停止 {stops}。"), + }; + Some(note) +} + +/// `2 个远程终端、1 个 SFTP 标签和 1 个本地终端`, leaving out what is none. +fn join(parts: &[(usize, &str)]) -> Option { + let parts: Vec = parts + .iter() + .filter(|(count, _)| *count > 0) + .map(|(count, noun)| format!("{count} {noun}")) + .collect(); + match parts.as_slice() { + [] => None, + [one] => Some(one.clone()), + [rest @ .., last] => Some(format!("{}和 {}", rest.join("、"), last)), + } +} + +#[cfg(test)] +mod tests { + use semver::Version; + + use super::*; + use crate::update::UpdateError; + use crate::update::install::Unsupported; + use crate::update::manifest::{Asset, Release}; + use crate::update::updater::ManualUpdate; + + fn snapshot(phase: Phase) -> UpdateSnapshot { + UpdateSnapshot { + phase, + current: Version::new(0, 1, 0), + release: Some(Release { + version: Version::new(0, 2, 0), + published_at: None, + notes: String::new(), + notes_url: None, + asset: Asset { + urls: Vec::new(), + size: 100, + sha256: String::new(), + }, + installer: None, + }), + manual: None, + checked_at: None, + unsupported: None, + automatic: true, + download_failures: 0, + } + } + + #[test] + fn restarting_says_what_it_interrupts() { + assert_eq!(restart_note(RestartImpact::default()), None); + assert_eq!( + restart_note(RestartImpact { + remote_terminals: 2, + sftp_tabs: 1, + local_terminals: 1, + transfers: 1, + forwards: 3, + }) + .as_deref(), + Some( + "重启会关闭 2 个远程终端、1 个 SFTP 标签和 1 个本地终端,\ + 停止 1 个传输(保留续传进度)和 3 条端口转发。" + ) + ); + assert_eq!( + restart_note(RestartImpact { + forwards: 1, + ..RestartImpact::default() + }) + .as_deref(), + Some("重启会停止 1 条端口转发。") + ); + } + + #[test] + fn each_phase_has_its_line() { + assert_eq!(snapshot(Phase::Checking).status_line().0, "正在检查更新…"); + assert_eq!( + snapshot(Phase::Downloading { + done: 45, + total: 100 + }) + .status_line() + .0, + "正在下载 0.2.0 · 45%" + ); + 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), + }); + assert_eq!( + failed.status_line(), + ( + "下载 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, "开发构建,不检查更新"); + } + + #[test] + fn the_title_bar_offers_a_ready_or_manual_version_and_repeated_failures() { + let label = |snapshot: &UpdateSnapshot| snapshot.badge().map(|badge| badge.label); + assert_eq!( + label(&snapshot(Phase::Ready)).as_deref(), + Some("新版本 0.2.0 已就绪") + ); + assert_eq!(snapshot(Phase::Available).badge(), None); + assert_eq!( + snapshot(Phase::Downloading { done: 1, total: 2 }).badge(), + None + ); + + let mut unsupported = snapshot(Phase::Available); + unsupported.unsupported = Some(Unsupported::NotAppImage); + assert_eq!(label(&unsupported).as_deref(), Some("新版本 0.2.0 可用")); + assert!(unsupported.needs_download_page()); + assert!(!unsupported.can_download()); + + let mut manual = snapshot(Phase::Available); + manual.release = None; + manual.manual = Some(ManualUpdate { + version: Version::new(3, 0, 0), + installer: None, + }); + assert_eq!(label(&manual).as_deref(), Some("新版本 3.0.0 可用")); + + let mut failing = snapshot(Phase::Failed { + stage: Stage::Download, + error: UpdateError::Stalled, + }); + failing.download_failures = 1; + assert_eq!(failing.badge(), None, "one failure is retried quietly"); + failing.download_failures = 2; + assert_eq!( + failing.badge(), + Some(UpdateBadge { + label: "新版本 0.2.0 下载失败".into(), + trouble: true, + }) + ); + assert!(failing.can_download()); + } + + #[test] + fn percent_never_overflows_its_bar() { + assert_eq!(percent(0, 0), 100); + assert_eq!(percent(50, 200), 25); + assert_eq!(percent(300, 200), 100); + } +} diff --git a/src/update/update_dialog.rs b/src/update/update_dialog.rs new file mode 100644 index 0000000..5b2ec97 --- /dev/null +++ b/src/update/update_dialog.rs @@ -0,0 +1,229 @@ +//! The update dialog: what the newer ShellRS brings, how far its download +//! is, and restarting into it. +//! +//! Opened from the title bar button and from 设置 › 关于. What a restart +//! interrupts is written here, above the button, so restarting needs no +//! second dialog on top of this one. + +use std::rc::Rc; + +use gpui_kit::component::{ + ActiveTheme as _, Sizable as _, WindowExt as _, + button::{Button, ButtonVariants as _}, + dialog::{DialogClose, DialogFooter}, + progress::Progress, + text::TextView, + v_flex, +}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::*; + +use crate::app::{DownloadUpdate, OpenDownloadPage, RestartToUpdate}; + +use super::status::{RestartImpact, percent, restart_note}; +use super::updater::{Phase, Stage, UpdateSnapshot, Updater}; + +/// The dialog's width, in rems. +const DIALOG_WIDTH: f32 = 32.; +/// The notes scroll beyond this height, in rems. +const NOTES_HEIGHT: f32 = 18.; + +/// Counts what a restart would interrupt, at the time the dialog draws. +pub type ImpactCounter = Rc RestartImpact>; + +/// The dialog's body. It observes the updater so the progress moves while +/// the dialog is open. +pub struct UpdateDialog { + updater: Entity, + impact: ImpactCounter, + _observer: Subscription, +} + +impl UpdateDialog { + fn new(updater: Entity, impact: ImpactCounter, cx: &mut Context) -> Self { + Self { + _observer: cx.observe(&updater, |_, _, cx| cx.notify()), + updater, + impact, + } + } +} + +impl Render for UpdateDialog { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let snapshot = self.updater.read(cx).snapshot(); + let impact = (self.impact)(cx); + let muted = cx.theme().muted_foreground; + let release = snapshot.release.clone(); + v_flex() + .id("update-dialog") + .test_support() + .gap_3() + .text_sm() + .child( + div() + .text_color(muted) + .child(match (&release, &snapshot.manual) { + (Some(release), _) => match release.published_on() { + Some(day) => format!("当前版本 {} · 发布于 {day}", snapshot.current), + None => format!("当前版本 {}", snapshot.current), + }, + _ => format!("当前版本 {}", snapshot.current), + }), + ) + .children( + release + .as_ref() + .filter(|release| !release.notes.trim().is_empty()) + .map(|release| { + div() + .id("update-notes") + .test_support() + .aria_label(release.notes.clone()) + .max_h(rems(NOTES_HEIGHT)) + .overflow_y_scroll() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) + .p_3() + .child( + TextView::markdown( + SharedString::from(format!("update-notes-{}", release.version)), + release.notes.clone(), + ) + .selectable(true), + ) + }), + ) + .when_some(progress(&snapshot), |body, (label, value)| { + body.child( + v_flex() + .gap_1() + .child( + div() + .id("update-progress") + .test_support() + .aria_label(label.clone()) + .text_color(muted) + .child(label), + ) + .child(Progress::new("update-progress-bar").value(value).small()), + ) + }) + .when_some(problem(&snapshot), |body, problem| { + body.child( + div() + .id("update-problem") + .test_support() + .aria_label(problem.clone()) + .text_color(cx.theme().danger) + .child(problem), + ) + }) + .when_some( + (snapshot.phase == Phase::Ready) + .then(|| restart_note(impact)) + .flatten(), + |body, note| { + body.child( + div() + .id("update-restart-note") + .test_support() + .aria_label(note.clone()) + .text_color(cx.theme().warning) + .child(note), + ) + }, + ) + } +} + +/// The progress line and bar while downloading or checking the package. +fn progress(snapshot: &UpdateSnapshot) -> Option<(String, f32)> { + match snapshot.phase { + Phase::Downloading { done, total } => { + let percent = percent(done, total); + Some((format!("正在下载 · {percent}%"), percent as f32)) + } + Phase::Verifying => Some(("正在校验…".into(), 100.)), + _ => None, + } +} + +/// Why the last step failed, or why this copy cannot install the update. +fn problem(snapshot: &UpdateSnapshot) -> Option { + match &snapshot.phase { + Phase::Failed { stage, error } => Some(match stage { + Stage::Check => format!("检查失败:{error}"), + Stage::Download => format!("下载失败:{error}"), + Stage::Install => format!("安装失败:{error}"), + }), + _ if snapshot.manual.is_some() => { + Some("这个版本需要从官网下载安装,无法在 ShellRS 中直接更新。".into()) + } + _ => snapshot.unsupported.as_ref().map(|reason| reason.reason()), + } +} + +/// Open the dialog for the version the updater has found. `dispatch` is +/// the workspace's focus handle, which handles the buttons' actions. +pub fn open_update_dialog( + updater: Entity, + impact: ImpactCounter, + dispatch: FocusHandle, + window: &mut Window, + cx: &mut App, +) { + let body = cx.new(|cx| UpdateDialog::new(updater.clone(), impact, cx)); + window.open_dialog(cx, move |dialog, window, cx| { + let snapshot = updater.read(cx).snapshot(); + let title = match snapshot.offered_version() { + Some(version) => format!("ShellRS {version}"), + None => "ShellRS 更新".into(), + }; + dialog + .title(title) + // Dialog geometry is an API boundary that takes `Pixels`; the + // width follows the interface zoom through the rem. + .w(rems(DIALOG_WIDTH).to_pixels(window.rem_size())) + // Closed by its buttons or Escape, not by a click beside it. + .overlay_closable(false) + .child(body.clone()) + .footer(footer(&snapshot, dispatch.clone())) + }); +} + +/// 稍后, 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 + }; + let close = if action.is_some() { "稍后" } else { "关闭" }; + DialogFooter::new() + .child(DialogClose::new().trigger(move |button| button.label(close))) + .children(action.map(|(id, label, action)| { + let dispatch = dispatch.clone(); + Button::new(id) + .primary() + .label(label) + .on_click(move |_, window, cx| { + dispatch.dispatch_action(action.as_ref(), window, cx); + }) + })) +} diff --git a/src/update/updater.rs b/src/update/updater.rs new file mode 100644 index 0000000..97795b4 --- /dev/null +++ b/src/update/updater.rs @@ -0,0 +1,853 @@ +//! The updater: when to look, what was found, the download, and the switch +//! to the new version. +//! +//! Network and disk work runs on a `shellrs-update` thread per job; the +//! entity collects its events on a timer while a job runs, like the port +//! forwards do, so no worker ever wakes the window. Results carry the job's +//! generation and are dropped once a newer job has replaced it. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +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; +use serde::{Deserialize, Serialize}; + +use super::build_info::{self, Channel}; +use super::error::UpdateError; +use super::feed::{HttpFeed, UpdateFeed}; +use super::install::{Installer, Launch, Relaunch, Staged, Unsupported, system_installer}; +use super::manifest::{Offer, Release}; +use super::verify::{Opened, TrustedKeys, open_envelope, verify_file}; + +/// The first look, a while after start so it does not compete with the +/// window coming up. +const FIRST_CHECK: Duration = Duration::from_secs(30); +/// Between looks, plus up to [`JITTER`]. +const CHECK_INTERVAL: Duration = Duration::from_secs(6 * 60 * 60); +const JITTER: Duration = Duration::from_secs(30 * 60); +/// How often a running job's events are collected. +const POLL_INTERVAL: Duration = Duration::from_millis(100); +/// A restart that has not ended ShellRS after this long has failed. +const RESTART_GRACE: Duration = Duration::from_secs(2); +/// Failed automatic downloads before the title bar offers to retry. +pub(super) const FAILURES_BEFORE_BADGE: u32 = 2; + +/// What the updater works with. Production has the real ones +/// ([`UpdateServices::system`]); the UI tests inject fakes. +#[derive(Clone)] +pub struct UpdateServices { + pub feed: Arc, + pub installer: Arc, + pub keys: TrustedKeys, + /// `None` for a development build, which never looks. + pub channel: Option, + pub current: Version, + /// Downloads, and the note the next start reads. + pub folder: PathBuf, + /// This machine's own number for staged rollouts, in `0..1`. + pub draw: f64, +} + +impl UpdateServices { + /// `bundle` is `cx.app_path()`, which on macOS is the app bundle. + pub fn system(bundle: Option) -> Self { + let folder = crate::app::updates_dir(); + Self { + feed: Arc::new(HttpFeed::system()), + installer: system_installer(bundle), + keys: TrustedKeys::builtin(), + channel: Channel::of_this_build(), + current: build_info::version(), + draw: rollout_draw(&folder), + folder, + } + } +} + +/// The number this machine drew for staged rollouts, drawn once and kept. +/// It never leaves the machine. +fn rollout_draw(folder: &Path) -> f64 { + let file = folder.join("rollout"); + if let Some(draw) = fs::read_to_string(&file) + .ok() + .and_then(|text| text.trim().parse::().ok()) + .filter(|draw| (0.0..1.0).contains(draw)) + { + return draw; + } + let draw = rand::rng().random_range(0.0..1.0); + let _ = fs::create_dir_all(folder).and_then(|()| fs::write(&file, draw.to_string())); + draw +} + +/// Where the updater stands. +#[derive(Clone, Debug, PartialEq)] +pub enum Phase { + /// Never looks: a development build, or no services at all. + Off, + /// Has not looked yet. + Idle, + Checking, + UpToDate, + /// A newer version, not downloaded: automatic updates are off, or this + /// copy cannot install it, or it needs the download page. + Available, + Downloading { + done: u64, + total: u64, + }, + Verifying, + /// Downloaded, verified and staged: installs on restart or quit. + Ready, + /// ShellRS is restarting or quitting into the new version. + Installing, + Failed { + stage: Stage, + error: UpdateError, + }, +} + +/// What a failure interrupted. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Stage { + Check, + Download, + Install, +} + +/// A newer version this copy cannot install itself. +#[derive(Clone, Debug, PartialEq)] +pub struct ManualUpdate { + pub version: Version, + pub installer: Option, +} + +/// Everything the settings page, the title bar and the dialog show, as a +/// plain value: render callbacks never read the entity. +#[derive(Clone, Debug, PartialEq)] +pub struct UpdateSnapshot { + pub phase: Phase, + 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, + /// Automatic downloads that failed in a row. + pub download_failures: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum UpdaterEvent { + /// The phase or the version on offer changed. Download progress alone + /// does not count: the window does not redraw for every chunk. + Changed, + /// This start follows an update. `completed` is false when ShellRS is + /// still the version it was before. + Updated { + from: Version, + to: Version, + completed: bool, + }, +} + +/// What a worker reports. +enum WorkerEvent { + Checked(Result), + Progress(u64), + Verifying, + Staged(Result), +} + +struct Job { + events: Receiver, + cancel: Arc, +} + +pub struct Updater { + services: Option, + phase: Phase, + release: Option, + manual: Option, + staged: Option, + checked_at: Option>, + automatic: bool, + download_failures: u32, + job: Option, + polling: bool, + _schedule: Option>, + _quit: Option, +} + +impl EventEmitter for Updater {} + +impl Default for Updater { + fn default() -> Self { + Self::new() + } +} + +impl Updater { + /// An updater with nothing to work with: it never looks. The workspace + /// gives production its services. + pub fn new() -> Self { + Self { + services: None, + phase: Phase::Off, + release: None, + manual: None, + staged: None, + checked_at: None, + automatic: true, + download_failures: 0, + job: None, + polling: false, + _schedule: None, + _quit: None, + } + } + + pub fn set_services(&mut self, services: UpdateServices, cx: &mut Context) { + self.cancel_job(); + self.phase = if services.channel.is_some() { + Phase::Idle + } else { + Phase::Off + }; + self.services = Some(services); + self.release = None; + self.manual = None; + self.staged = None; + self.changed(cx); + } + + /// Look on a schedule, tidy up after the previous update, report it, + /// and install a ready update when ShellRS quits. Production only. + pub fn start(&mut self, cx: &mut Context) { + self.report_previous_update(cx); + self._quit = Some(cx.on_app_quit(|this, cx| { + this.install_on_quit(cx); + async {} + })); + self._schedule = Some(cx.spawn(async move |this, cx| { + cx.background_executor().timer(FIRST_CHECK).await; + if this.update(cx, |this, cx| this.clean_up(cx)).is_err() { + return; + } + loop { + let looked = this.update(cx, |this, cx| { + if this.automatic { + this.check(cx); + } + }); + if looked.is_err() { + break; + } + let jitter = rand::rng().random_range(0..JITTER.as_secs()); + let wait = CHECK_INTERVAL + Duration::from_secs(jitter); + cx.background_executor().timer(wait).await; + } + })); + } + + pub fn snapshot(&self) -> UpdateSnapshot { + UpdateSnapshot { + phase: self.phase.clone(), + current: self + .services + .as_ref() + .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, + } + } + + pub fn phase(&self) -> &Phase { + &self.phase + } + + /// Why this copy cannot install updates itself, when it cannot. + pub fn unsupported(&self) -> Option { + let Some(services) = &self.services else { + return Some(Unsupported::DevelopmentBuild); + }; + if services.channel.is_none() { + return Some(Unsupported::DevelopmentBuild); + } + match services.installer.kind() { + super::install::InstallKind::Unsupported(reason) => Some(reason.clone()), + _ => None, + } + } + + fn installable(&self) -> bool { + self.unsupported().is_none() + } + + /// 自动检查并下载更新. 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; + } + self.automatic = automatic; + if !automatic && matches!(self.phase, Phase::Downloading { .. }) { + self.cancel_job(); + self.phase = Phase::Available; + } else if automatic && self.phase == Phase::Available { + self.download(cx); + } + self.changed(cx); + } + + /// Look for a newer version now. + pub fn check(&mut self, cx: &mut Context) { + if !matches!( + self.phase, + Phase::Idle + | Phase::UpToDate + | Phase::Available + | Phase::Failed { .. } + | Phase::Checking + ) { + return; + } + let Some(services) = self.services.clone() else { + return; + }; + let Some(channel) = services.channel else { + return; + }; + self.phase = Phase::Checking; + self.run(cx, move |events, _| { + let opened = services + .feed + .fetch(channel) + .and_then(|bytes| open_envelope(&bytes, channel, &services.keys)); + let _ = events.send_blocking(WorkerEvent::Checked(opened)); + }); + self.changed(cx); + } + + /// Download the version found, when this copy can install it. + pub fn download(&mut self, cx: &mut Context) { + let ready_to_download = matches!( + self.phase, + Phase::Available + | Phase::Failed { + stage: Stage::Download | Stage::Install, + .. + } + ); + if !ready_to_download || !self.installable() { + return; + } + let (Some(services), Some(release)) = (self.services.clone(), self.release.clone()) else { + return; + }; + self.phase = Phase::Downloading { + done: 0, + total: release.asset.size, + }; + self.run(cx, move |events, cancel| { + let staged = download_and_stage(&services, &release, events, cancel); + let _ = events.send_blocking(WorkerEvent::Staged(staged)); + }); + self.changed(cx); + } + + /// Put the ready version in place and restart into it. + pub fn restart(&mut self, cx: &mut Context) { + let Some(relaunch) = self.apply(true, cx) else { + return; + }; + match relaunch { + Relaunch::Restart(path) => { + cx.set_restart_path(path); + cx.restart(); + // A restart that cannot start the helper script leaves + // ShellRS running; say so instead of looking stuck. + cx.spawn(async move |this, cx| { + cx.background_executor().timer(RESTART_GRACE).await; + let _ = this.update(cx, |this, cx| { + if this.phase == Phase::Installing { + this.phase = Phase::Failed { + stage: Stage::Install, + error: UpdateError::Install( + "无法自动重新启动,请手动重新打开 ShellRS".into(), + ), + }; + this.changed(cx); + } + }); + }) + .detach(); + } + Relaunch::Hand(launch) => { + hand_over(launch); + cx.quit(); + } + Relaunch::Nothing => {} + } + } + + /// ShellRS is quitting with a version ready: put it in place now, the + /// way it would have been on restart, so the next start runs it. + fn install_on_quit(&mut self, cx: &mut Context) { + if let Some(Relaunch::Hand(launch)) = self.apply(false, cx) { + hand_over(launch); + } + } + + fn apply(&mut self, relaunch: bool, cx: &mut Context) -> Option { + if self.phase != Phase::Ready { + return None; + } + let (services, staged) = (self.services.clone()?, self.staged.clone()?); + let note = Applied { + from: services.current.clone(), + to: staged.version.clone(), + }; + let note_file = services.folder.join(APPLIED); + let _ = serde_json::to_vec(¬e) + .ok() + .map(|bytes| fs::write(¬e_file, bytes)); + match services.installer.apply(&staged, relaunch) { + Ok(relaunch) => { + self.phase = Phase::Installing; + self.changed(cx); + Some(relaunch) + } + Err(error) => { + let _ = fs::remove_file(¬e_file); + self.staged = None; + self.phase = Phase::Failed { + stage: Stage::Install, + error, + }; + self.changed(cx); + None + } + } + } + + /// The download page, or this platform's installer when known. + pub fn download_page(&self) -> String { + self.release + .as_ref() + .and_then(|release| release.installer.clone()) + .or_else(|| { + self.manual + .as_ref() + .and_then(|manual| manual.installer.clone()) + }) + .unwrap_or_else(|| build_info::DOWNLOAD_PAGE.to_string()) + } + + /// Read and remove the note the previous version left before it + /// installed this one. + fn report_previous_update(&mut self, cx: &mut Context) { + let Some(services) = &self.services else { + return; + }; + let file = services.folder.join(APPLIED); + let Ok(bytes) = fs::read(&file) else { + return; + }; + let _ = fs::remove_file(&file); + if let Ok(note) = serde_json::from_slice::(&bytes) { + let completed = services.current == note.to; + cx.emit(UpdaterEvent::Updated { + from: note.from, + to: note.to, + completed, + }); + } + } + + /// Remove what earlier updates left: the installed copy's backups, and + /// downloads for versions this one is not older than. + fn clean_up(&mut self, cx: &mut Context) { + let Some(services) = self.services.clone() else { + return; + }; + cx.background_spawn(async move { + services.installer.clean_up(); + clean_downloads(&services.folder, &services.current); + }) + .detach(); + } + + /// Start a job on its own thread, replacing the one running. + fn run( + &mut self, + cx: &mut Context, + job: impl FnOnce(&Sender, &AtomicBool) + Send + 'static, + ) { + self.cancel_job(); + let (sender, events) = async_channel::unbounded(); + let cancel = Arc::new(AtomicBool::new(false)); + let worker_cancel = cancel.clone(); + let spawned = std::thread::Builder::new() + .name("shellrs-update".into()) + .spawn(move || job(&sender, &worker_cancel)); + if let Err(error) = spawned { + self.phase = Phase::Failed { + stage: Stage::Check, + error: UpdateError::Disk(error.to_string()), + }; + return; + } + self.job = Some(Job { events, cancel }); + self.poll_while_running(cx); + } + + fn cancel_job(&mut self) { + if let Some(job) = self.job.take() { + job.cancel.store(true, Ordering::Relaxed); + } + } + + fn poll_while_running(&mut self, cx: &mut Context) { + if self.polling { + return; + } + self.polling = true; + cx.spawn(async move |this, cx| { + loop { + cx.background_executor().timer(POLL_INTERVAL).await; + let running = this.update(cx, |this, cx| { + this.collect_events(cx); + this.polling = this.job.is_some(); + this.polling + }); + if !matches!(running, Ok(true)) { + break; + } + } + }) + .detach(); + } + + fn collect_events(&mut self, cx: &mut Context) { + while let Some(job) = &self.job { + match job.events.try_recv() { + Ok(event) => self.on_event(event, cx), + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Closed) => { + self.job = None; + if matches!( + self.phase, + Phase::Checking | Phase::Downloading { .. } | Phase::Verifying + ) { + self.phase = Phase::Failed { + stage: Stage::Check, + error: UpdateError::Network("检查意外中止".into()), + }; + self.changed(cx); + } + } + } + } + } + + fn on_event(&mut self, event: WorkerEvent, cx: &mut Context) { + match event { + WorkerEvent::Checked(result) => { + self.job = None; + self.on_checked(result, cx); + } + WorkerEvent::Progress(done) => { + if let Phase::Downloading { total, .. } = self.phase { + self.phase = Phase::Downloading { done, total }; + // Progress only: observers redraw, the window does not. + cx.notify(); + } + } + WorkerEvent::Verifying => { + self.phase = Phase::Verifying; + self.changed(cx); + } + WorkerEvent::Staged(result) => { + self.job = None; + match result { + Ok(staged) => { + self.staged = Some(staged); + self.download_failures = 0; + self.phase = Phase::Ready; + } + Err(UpdateError::Cancelled) => self.phase = Phase::Available, + Err(error) => { + self.download_failures += 1; + self.phase = Phase::Failed { + stage: Stage::Download, + error, + }; + } + } + self.changed(cx); + } + } + } + + fn on_checked(&mut self, result: Result, cx: &mut Context) { + self.checked_at = Some(Local::now()); + let Some(services) = &self.services else { + return; + }; + let offer = match result { + Ok(Opened::Manifest(manifest)) => manifest.offer( + &services.current, + super::platform::platform_key(), + services.draw, + ), + Ok(Opened::NewerFormat { version }) if version > services.current => Offer::Manual { + version, + installer: None, + }, + Ok(Opened::NewerFormat { .. }) => Offer::UpToDate, + Err(error) => { + self.phase = Phase::Failed { + stage: Stage::Check, + error, + }; + self.changed(cx); + return; + } + }; + match offer { + Offer::UpToDate => { + self.release = None; + self.manual = None; + self.phase = Phase::UpToDate; + } + Offer::Update(release) => { + if self.release.as_ref().map(|known| &known.version) != Some(&release.version) { + self.download_failures = 0; + } + self.release = Some(release); + self.manual = None; + self.phase = Phase::Available; + if self.automatic && self.installable() { + self.download(cx); + } + } + Offer::Manual { version, installer } => { + self.release = None; + self.manual = Some(ManualUpdate { version, installer }); + self.phase = Phase::Available; + } + } + self.changed(cx); + } + + fn changed(&mut self, cx: &mut Context) { + cx.emit(UpdaterEvent::Changed); + cx.notify(); + } +} + +/// The note `apply` leaves for the next start, in the downloads folder. +const APPLIED: &str = "applied.json"; + +#[derive(Debug, Serialize, Deserialize)] +struct Applied { + from: Version, + to: Version, +} + +/// Download the release's package (or reuse one already verified), check +/// it, and stage it. Runs on the job's thread. +fn download_and_stage( + services: &UpdateServices, + release: &Release, + events: &Sender, + cancel: &AtomicBool, +) -> Result { + let folder = services.folder.join(release.version.to_string()); + fs::create_dir_all(&folder)?; + let package = folder.join(package_name(release)); + let verified = package.exists() && verify_file(&package, &release.asset).is_ok(); + if !verified { + let _ = fs::remove_file(&package); + let mut last_sent = 0; + services.feed.download( + &release.asset.urls, + release.asset.size, + &package, + &mut |done| { + // About every percent: the window does not need every chunk. + if done == release.asset.size || done >= last_sent + release.asset.size / 100 { + last_sent = done; + let _ = events.send_blocking(WorkerEvent::Progress(done)); + } + }, + cancel, + )?; + let _ = events.send_blocking(WorkerEvent::Verifying); + if let Err(error) = verify_file(&package, &release.asset) { + let _ = fs::remove_file(&package); + return Err(error); + } + } else { + let _ = events.send_blocking(WorkerEvent::Verifying); + } + if cancel.load(Ordering::Relaxed) { + return Err(UpdateError::Cancelled); + } + services.installer.stage(&package, release) +} + +/// The package's file name, from its address: the installers recognise it +/// by its extension. Anything unusual in it is replaced. +fn package_name(release: &Release) -> String { + let name = release + .asset + .urls + .first() + .and_then(|url| url.rsplit('/').next()) + .unwrap_or_default(); + let name: String = name + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || "._-".contains(c) { + c + } else { + '_' + } + }) + .collect(); + if name.trim_matches(['.', '_']).is_empty() { + "package".into() + } else { + name + } +} + +/// Remove downloads of versions not newer than `current`, and stray +/// partial ones. +fn clean_downloads(folder: &Path, current: &Version) { + for entry in fs::read_dir(folder).into_iter().flatten().flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let name = entry.file_name().to_string_lossy().into_owned(); + let stale = Version::parse(&name).map_or(true, |version| version <= *current); + if stale { + let _ = fs::remove_dir_all(&path); + } + } +} + +/// Windows hands the install to a process started once ShellRS's event +/// loop has ended: starting a process can pump the message loop, which must +/// not happen while GPUI is busy quitting. +static HANDED_OVER: Mutex> = Mutex::new(None); + +fn hand_over(launch: Launch) { + *HANDED_OVER + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(launch); +} + +/// Start what an update handed over, if anything. `main` calls this after +/// the application has stopped running. +pub fn start_handed_over() { + let launch = HANDED_OVER + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + if let Some(launch) = launch + && let Err(error) = launch.spawn() + { + eprintln!("shellrs: 无法启动更新安装程序:{error}"); + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::Path; + + use semver::Version; + + use super::{clean_downloads, package_name, rollout_draw}; + use crate::update::feed::part_path; + use crate::update::manifest::{Asset, Release}; + + fn release(urls: &[&str]) -> Release { + Release { + version: Version::new(0, 2, 0), + published_at: None, + notes: String::new(), + notes_url: None, + asset: Asset { + urls: urls.iter().map(|url| url.to_string()).collect(), + size: 1, + sha256: String::new(), + }, + installer: None, + } + } + + #[test] + fn a_package_is_named_after_its_address() { + assert_eq!( + package_name(&release(&[ + "https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-universal.app.zip" + ])), + "ShellRS-0.2.0-macos-universal.app.zip" + ); + assert_eq!(package_name(&release(&["https://x/a b?.exe"])), "a_b_.exe"); + assert_eq!(package_name(&release(&["https://x/.."])), "package"); + assert_eq!(package_name(&release(&[])), "package"); + } + + #[test] + fn downloads_of_older_versions_are_cleaned_up() { + let folder = tempfile::tempdir().unwrap(); + for name in ["0.1.0", "0.2.0", "0.3.0", "junk"] { + fs::create_dir_all(folder.path().join(name)).unwrap(); + } + fs::write(folder.path().join("rollout"), "0.5").unwrap(); + clean_downloads(folder.path(), &Version::new(0, 2, 0)); + let mut left: Vec = fs::read_dir(folder.path()) + .unwrap() + .flatten() + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect(); + left.sort(); + assert_eq!(left, ["0.3.0", "rollout"]); + } + + #[test] + fn the_rollout_draw_is_kept() { + let folder = tempfile::tempdir().unwrap(); + let first = rollout_draw(folder.path()); + assert!((0.0..1.0).contains(&first)); + assert_eq!(rollout_draw(folder.path()), first); + fs::write(folder.path().join("rollout"), "7").unwrap(); + assert!((0.0..1.0).contains(&rollout_draw(folder.path()))); + } + + #[test] + fn a_partial_download_sits_next_to_its_package() { + assert_eq!( + part_path(Path::new("/u/0.2.0/setup.exe")), + Path::new("/u/0.2.0/setup.exe.part") + ); + } +} diff --git a/src/update/verify.rs b/src/update/verify.rs new file mode 100644 index 0000000..f06f2be --- /dev/null +++ b/src/update/verify.rs @@ -0,0 +1,376 @@ +//! Trusting what the update server sends. +//! +//! The manifest is signed as a whole: its notes are shown with clickable +//! links and its addresses are where the next ShellRS comes from, so none of +//! it is believed before the signature checks out. The signature's trusted +//! comment, `shellrs-manifest `, is covered by the +//! signature too; matching it against the manifest stops an old, genuinely +//! signed manifest from being served as a newer one, and a beta manifest +//! from being served on the stable channel. A package is then trusted by its +//! size and SHA-256, which the signed manifest gives. + +use std::fs::File; +use std::io::Read; +use std::path::Path; + +use minisign_verify::{PublicKey, Signature}; +use semver::Version; +use sha2::{Digest as _, Sha256}; + +use super::build_info::{Channel, TRUSTED_KEYS}; +use super::error::UpdateError; +use super::manifest::{self, Asset, Envelope, Manifest}; + +/// The keys a manifest may be signed with. +#[derive(Clone, Debug, Default)] +pub struct TrustedKeys(Vec); + +impl TrustedKeys { + /// Keys in minisign's base64 form, the second line of a `.pub` file. + pub fn new<'a>(keys: impl IntoIterator) -> Self { + Self( + keys.into_iter() + .filter_map(|key| PublicKey::from_base64(key.trim()).ok()) + .collect(), + ) + } + + /// The keys built into this ShellRS. A debug build also trusts the key + /// in `SHELLRS_UPDATE_PUBLIC_KEY`, to try updating against a local + /// server; a release build trusts only the keys it was built with. + pub fn builtin() -> Self { + let test_key = cfg!(debug_assertions) + .then(|| std::env::var("SHELLRS_UPDATE_PUBLIC_KEY").ok()) + .flatten(); + Self::new(TRUSTED_KEYS.iter().copied().chain(test_key.as_deref())) + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +/// A manifest whose signature checked out. +#[derive(Clone, Debug, PartialEq)] +pub enum Opened { + Manifest(Manifest), + /// Signed, but in a format newer than this build reads. The signed + /// version still says whether there is something to download by hand. + NewerFormat { + version: Version, + }, +} + +/// Check the envelope's signature and open the manifest inside. +pub fn open_envelope( + bytes: &[u8], + channel: Channel, + keys: &TrustedKeys, +) -> Result { + let envelope: Envelope = serde_json::from_slice(bytes) + .map_err(|error| UpdateError::BadManifest(error.to_string()))?; + let signature = + Signature::decode(&envelope.signature).map_err(|_| UpdateError::BadSignature)?; + let signed = keys.0.iter().any(|key| { + key.verify(envelope.manifest.as_bytes(), &signature, false) + .is_ok() + }); + if !signed { + return Err(UpdateError::BadSignature); + } + let version = + signed_version(signature.trusted_comment(), channel).ok_or(UpdateError::BadSignature)?; + match manifest::parse(&envelope.manifest) + .map_err(|error| UpdateError::BadManifest(error.to_string()))? + { + None => Ok(Opened::NewerFormat { version }), + Some(manifest) if manifest.channel == channel && manifest.version == version => { + Ok(Opened::Manifest(manifest)) + } + Some(_) => Err(UpdateError::BadSignature), + } +} + +/// The version a trusted comment vouches for on `channel`. +fn signed_version(comment: &str, channel: Channel) -> Option { + let rest = comment.strip_prefix("shellrs-manifest ")?; + let (signed_channel, version) = rest.split_once(' ')?; + if signed_channel != channel.key() { + return None; + } + Version::parse(version.trim()).ok() +} + +/// Whether the file at `path` is the package `asset` describes. +pub fn verify_file(path: &Path, asset: &Asset) -> Result<(), UpdateError> { + if std::fs::metadata(path)?.len() != asset.size { + return Err(UpdateError::Corrupt); + } + let mut file = File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = vec![0; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + let digest: String = hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + if digest.eq_ignore_ascii_case(asset.sha256.trim()) { + Ok(()) + } else { + Err(UpdateError::Corrupt) + } +} + +#[cfg(test)] +pub(crate) mod tests { + use std::io::Cursor; + + use minisign::KeyPair; + + use super::*; + use crate::update::manifest::tests::manifest_text; + + /// A key pair for tests, and its public key as `TrustedKeys` takes it. + pub(crate) fn test_key() -> (KeyPair, String) { + let pair = KeyPair::generate_unencrypted_keypair().unwrap(); + let public = pair.pk.to_base64(); + (pair, public) + } + + /// An envelope as the release workflow publishes it. + pub(crate) fn envelope(pair: &KeyPair, manifest: &str, comment: &str) -> Vec { + let signature = minisign::sign( + Some(&pair.pk), + &pair.sk, + Cursor::new(manifest.as_bytes()), + Some(comment), + None, + ) + .unwrap() + .into_string(); + serde_json::to_vec(&serde_json::json!({ + "manifest": manifest, + "signature": signature, + })) + .unwrap() + } + + #[test] + fn a_manifest_signed_by_either_trusted_key_is_accepted() { + let (current, current_public) = test_key(); + let (spare, spare_public) = test_key(); + let keys = TrustedKeys::new([current_public.as_str(), spare_public.as_str()]); + for pair in [¤t, &spare] { + let bytes = envelope( + pair, + &manifest_text("0.2.0"), + "shellrs-manifest stable 0.2.0", + ); + let Ok(Opened::Manifest(manifest)) = open_envelope(&bytes, Channel::Stable, &keys) + else { + panic!("a manifest signed by a trusted key opens"); + }; + assert_eq!(manifest.version, Version::new(0, 2, 0)); + } + } + + #[test] + fn a_tampered_or_unknown_key_manifest_is_rejected() { + let (pair, public) = test_key(); + let keys = TrustedKeys::new([public.as_str()]); + let signed = envelope( + &pair, + &manifest_text("0.2.0"), + "shellrs-manifest stable 0.2.0", + ); + let tampered = String::from_utf8(signed) + .unwrap() + .replace("a.zip", "evil.zip") + .into_bytes(); + assert_eq!( + open_envelope(&tampered, Channel::Stable, &keys), + Err(UpdateError::BadSignature) + ); + + let (stranger, _) = test_key(); + let unknown = envelope( + &stranger, + &manifest_text("0.2.0"), + "shellrs-manifest stable 0.2.0", + ); + assert_eq!( + open_envelope(&unknown, Channel::Stable, &keys), + Err(UpdateError::BadSignature) + ); + assert_eq!( + open_envelope(&unknown, Channel::Stable, &TrustedKeys::default()), + Err(UpdateError::BadSignature) + ); + assert!(matches!( + open_envelope(b"", Channel::Stable, &keys), + Err(UpdateError::BadManifest(_)) + )); + } + + #[test] + fn an_old_manifest_signed_as_a_newer_version_is_rejected() { + let (pair, public) = test_key(); + let keys = TrustedKeys::new([public.as_str()]); + let replayed = envelope( + &pair, + &manifest_text("0.2.0"), + "shellrs-manifest stable 0.9.0", + ); + assert_eq!( + open_envelope(&replayed, Channel::Stable, &keys), + Err(UpdateError::BadSignature) + ); + } + + #[test] + fn a_beta_manifest_served_as_stable_is_rejected() { + let (pair, public) = test_key(); + let keys = TrustedKeys::new([public.as_str()]); + let beta = manifest_text("0.3.0-beta.1").replace("\"stable\"", "\"beta\""); + let bytes = envelope(&pair, &beta, "shellrs-manifest beta 0.3.0-beta.1"); + assert_eq!( + open_envelope(&bytes, Channel::Stable, &keys), + Err(UpdateError::BadSignature) + ); + assert!(matches!( + open_envelope(&bytes, Channel::Beta, &keys), + Ok(Opened::Manifest(_)) + )); + } + + #[test] + fn a_signed_manifest_in_a_newer_format_still_names_its_version() { + let (pair, public) = test_key(); + let keys = TrustedKeys::new([public.as_str()]); + let bytes = envelope(&pair, r#"{"schema": 2}"#, "shellrs-manifest stable 3.0.0"); + assert_eq!( + open_envelope(&bytes, Channel::Stable, &keys), + Ok(Opened::NewerFormat { + version: Version::new(3, 0, 0) + }) + ); + } + + #[test] + fn a_download_of_the_wrong_size_or_hash_is_rejected() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("package"); + std::fs::write(&path, b"abc").unwrap(); + let asset = |size, sha256: &str| Asset { + urls: Vec::new(), + size, + sha256: sha256.into(), + }; + let sha_of_abc = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + assert_eq!(verify_file(&path, &asset(3, sha_of_abc)), Ok(())); + assert_eq!( + verify_file(&path, &asset(3, &sha_of_abc.to_uppercase())), + Ok(()) + ); + assert_eq!( + verify_file(&path, &asset(4, sha_of_abc)), + Err(UpdateError::Corrupt) + ); + assert_eq!( + verify_file(&path, &asset(3, &"0".repeat(64))), + Err(UpdateError::Corrupt) + ); + } + + /// The release workflow's `packaging/make-manifest.sh` and this module + /// agree on the envelope. Needs the `minisign` and `jq` commands, so it + /// passes quietly where they are not installed. + #[test] + fn the_release_script_signs_what_the_client_accepts() { + let have = |tool: &str| { + std::process::Command::new(tool) + .arg(if tool == "jq" { "--version" } else { "-v" }) + .output() + .is_ok_and(|output| output.status.success()) + }; + if !have("minisign") || !have("jq") { + eprintln!("skipped: minisign or jq is not installed"); + return; + } + let dir = tempfile::tempdir().unwrap(); + let (public, secret) = (dir.path().join("key.pub"), dir.path().join("key.sec")); + let generated = std::process::Command::new("minisign") + .args(["-G", "-W", "-p"]) + .arg(&public) + .arg("-s") + .arg(&secret) + .output() + .unwrap(); + assert!(generated.status.success(), "{generated:?}"); + let dist = dir.path().join("dist"); + std::fs::create_dir(&dist).unwrap(); + for name in [ + "ShellRS-0.2.0-macos-universal.app.zip", + "ShellRS-0.2.0-windows-x86_64-setup.exe", + "ShellRS-0.2.0-linux-x86_64.AppImage", + ] { + std::fs::write(dist.join(name), name).unwrap(); + } + let notes = dir.path().join("notes.md"); + std::fs::write(¬es, "### 新增\n\n- 在线升级\n").unwrap(); + let out = dir.path().join("stable.json"); + let script = concat!(env!("CARGO_MANIFEST_DIR"), "/packaging/make-manifest.sh"); + let made = std::process::Command::new("bash") + .arg(script) + .args(["0.2.0", "stable"]) + .arg(¬es) + .arg(&dist) + .arg(&out) + .env("MINISIGN_KEY_FILE", &secret) + .env_remove("MINISIGN_PASSWORD") + .output() + .unwrap(); + assert!(made.status.success(), "{made:?}"); + + let public = std::fs::read_to_string(&public).unwrap(); + let keys = TrustedKeys::new(public.lines().nth(1)); + let bytes = std::fs::read(&out).unwrap(); + let Ok(Opened::Manifest(manifest)) = open_envelope(&bytes, Channel::Stable, &keys) else { + panic!("the client rejects the script's manifest"); + }; + assert_eq!(manifest.version, Version::new(0, 2, 0)); + assert_eq!(manifest.notes, "### 新增\n\n- 在线升级\n"); + let mac = &manifest.assets["macos-aarch64"]; + assert_eq!(mac, &manifest.assets["macos-x86_64"]); + assert_eq!( + mac.urls, + ["https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-universal.app.zip"] + ); + let package = dist.join("ShellRS-0.2.0-linux-x86_64.AppImage"); + assert_eq!( + verify_file(&package, &manifest.assets["linux-x86_64"]), + Ok(()) + ); + assert!( + manifest.installers["windows-x86_64"].ends_with("windows-x86_64-setup.exe"), + "{:?}", + manifest.installers + ); + assert_eq!( + open_envelope(&bytes, Channel::Beta, &keys), + Err(UpdateError::BadSignature) + ); + } + + #[test] + fn the_builtin_keys_all_parse() { + assert_eq!(TrustedKeys::builtin().0.len(), TRUSTED_KEYS.len()); + } +} diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index 858e49c..adad115 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -8,6 +8,7 @@ mod recent_sessions; mod sidebar; mod status_bar; mod title_bar; +mod updates; mod workspace_view; pub use workspace_view::{Workspace, notify_once_open, window_options}; diff --git a/src/workspace/title_bar.rs b/src/workspace/title_bar.rs index f6f1f7a..0c4079c 100644 --- a/src/workspace/title_bar.rs +++ b/src/workspace/title_bar.rs @@ -1,5 +1,6 @@ use gpui_kit::component::{ ActiveTheme as _, IconName, Selectable as _, Sizable as _, TitleBar, + badge::Badge, button::{Button, ButtonGroup, ButtonVariants as _}, h_flex, }; @@ -8,8 +9,9 @@ use gpui_kit::*; use crate::app::{ CatalogIcon, NewLocalTerminal, NewSession, ShowCredentials, ShowForwards, ShowSessions, - ToggleSessionPanel, ToggleTheme, + ShowUpdate, ToggleSessionPanel, ToggleTheme, }; +use crate::update::UpdateBadge; use super::sidebar::SidebarMode; @@ -19,15 +21,18 @@ use super::sidebar::SidebarMode; /// when nothing is focused. /// /// `sidebar` is what the left dock is showing, or `None` while it is hidden; -/// `forwards` is how many port forwards are running. +/// `forwards` is how many port forwards are running; `update` is the +/// newer ShellRS waiting to be installed, when there is one. pub fn render_title_bar( sidebar: Option, forwards: usize, + update: Option, target: &FocusHandle, cx: &App, ) -> TitleBar { let dark = cx.theme().is_dark(); - let (mode, toggle, new_session, new_local, theme) = ( + let (mode, toggle, new_session, new_local, theme, show_update) = ( + target.clone(), target.clone(), target.clone(), target.clone(), @@ -140,6 +145,27 @@ pub fn render_title_bar( new_local.dispatch_action(&NewLocalTerminal, window, cx) }), ) + .children(update.map(|update| { + // A dot on the icon, in the colour of what it says. + Badge::new() + .dot() + .color(if update.trouble { + cx.theme().warning + } else { + cx.theme().success + }) + .child( + Button::new("update-available") + .ghost() + .small() + .icon(CatalogIcon::CircleArrowUp) + .tooltip(update.label.clone()) + .accessibility_label(update.label) + .on_click(move |_, window, cx| { + show_update.dispatch_action(&ShowUpdate, window, cx) + }), + ) + })) .child( Button::new("theme-toggle") .ghost() diff --git a/src/workspace/updates.rs b/src/workspace/updates.rs new file mode 100644 index 0000000..a4d5342 --- /dev/null +++ b/src/workspace/updates.rs @@ -0,0 +1,147 @@ +//! The workspace's share of updating: the commands, what a restart would +//! interrupt, and the word after an update. + +use std::rc::Rc; + +use gpui_kit::component::{WindowExt as _, notification::Notification}; +use gpui_kit::*; + +use crate::app::{CheckForUpdates, DownloadUpdate, OpenDownloadPage, RestartToUpdate, ShowUpdate}; +use crate::session::ConnectionState; +use crate::terminal::TerminalLifecycle; +use crate::update::{RestartImpact, UpdaterEvent, open_update_dialog}; + +use super::workspace_view::Workspace; + +impl Workspace { + pub(super) fn on_check_for_updates( + &mut self, + _: &CheckForUpdates, + _: &mut Window, + cx: &mut Context, + ) { + self.updater.update(cx, |updater, cx| updater.check(cx)); + } + + pub(super) fn on_download_update( + &mut self, + _: &DownloadUpdate, + _: &mut Window, + cx: &mut Context, + ) { + self.updater.update(cx, |updater, cx| updater.download(cx)); + } + + pub(super) fn on_show_update( + &mut self, + _: &ShowUpdate, + window: &mut Window, + cx: &mut Context, + ) { + let updater = self.updater.read(cx).snapshot(); + if updater.offered_version().is_none() { + return; + } + let workspace = cx.weak_entity(); + let impact = Rc::new(move |cx: &App| { + workspace + .read_with(cx, |workspace, cx| workspace.restart_impact(cx)) + .unwrap_or_default() + }); + open_update_dialog( + self.updater.clone(), + impact, + self.focus_handle.clone(), + window, + cx, + ); + } + + pub(super) fn on_restart_to_update( + &mut self, + _: &RestartToUpdate, + _: &mut Window, + cx: &mut Context, + ) { + self.updater.update(cx, |updater, cx| updater.restart(cx)); + } + + pub(super) fn on_open_download_page( + &mut self, + _: &OpenDownloadPage, + _: &mut Window, + cx: &mut Context, + ) { + let page = self.updater.read(cx).download_page(); + cx.open_url(&page); + } + + /// The title bar shows the update button only in some phases; the + /// version after an update is said once the new one runs. + pub(super) fn on_updater_event( + &mut self, + event: &UpdaterEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + UpdaterEvent::Changed => cx.notify(), + UpdaterEvent::Updated { + to, + completed: true, + .. + } => window.push_notification( + Notification::success(format!("ShellRS 已更新到 {to}。")).title("更新完成"), + cx, + ), + UpdaterEvent::Updated { from, to, .. } => window.push_notification( + Notification::error(format!( + "没有装上 {to},ShellRS 仍是 {from}。可以到官网下载安装。" + )) + .title("更新没有完成"), + cx, + ), + } + } + + /// 自动检查并下载更新, 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)); + } + + /// What restarting now would close or stop. + fn restart_impact(&self, cx: &App) -> RestartImpact { + RestartImpact { + remote_terminals: self + .terminals + .values() + .filter(|panel| panel.read(cx).lifecycle(cx) == TerminalLifecycle::Running) + .count(), + sftp_tabs: self + .explorers + .values() + .filter(|panel| panel.read(cx).connection_state() == ConnectionState::Connected) + .count(), + local_terminals: self + .local_terminals + .values() + .filter(|panel| { + panel + .read(cx) + .terminal() + .read(cx) + .lifecycle(cx) + .accepts_input() + }) + .count(), + transfers: self + .explorers + .values() + .filter(|panel| panel.read(cx).is_transferring()) + .count(), + forwards: self.forwards.read(cx).active_count(), + } + } +} diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index a8653f2..4f40edc 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -60,6 +60,7 @@ use crate::terminal::{ SharedTerminalTransportFactory, TerminalLifecycle, TerminalPanel, TerminalPanelEvent, TerminalView, }; +use crate::update::{UpdateServices, Updater, UpdaterEvent}; use super::{ dock_skin::WorkspaceDockSkin, recent_sessions::RecentSessions, sidebar::Sidebar, @@ -133,10 +134,10 @@ pub struct Workspace { pub(super) forwards: Entity, /// The start page the dock skin shows while the center has no tab. recent: Entity, - terminals: HashMap>, + pub(super) terminals: HashMap>, /// SFTP tabs; a session can have several, like terminals. - explorers: HashMap>, - local_terminals: HashMap>, + pub(super) explorers: HashMap>, + pub(super) local_terminals: HashMap>, /// The settings tab, while it is open. There is only ever one. settings_tab: Option>, /// What the settings tab edits; the workspace applies it to the window. @@ -148,6 +149,10 @@ pub struct Workspace { /// Answers the `shellrs` command. Production only: UI tests never /// listen on the real socket. cli_server: Option, + /// Looks for, downloads and installs newer versions. Without services + /// in UI tests, so it never reaches the network unless a test hands it + /// fakes. + pub(super) updater: Entity, local_terminal_factory: SharedTerminalTransportFactory, remote_terminal_provider: SharedRemoteTerminalTransportProvider, sftp_provider: SharedSftpTransportProvider, @@ -163,7 +168,7 @@ pub struct Workspace { pub(super) active_prompt: Option<(PromptOwner, SessionId, u64)>, /// Dispatch target for the title bar and start page: actions sent to it /// reach the workspace handlers whatever is focused. - focus_handle: FocusHandle, + pub(super) focus_handle: FocusHandle, _subscriptions: Vec, } @@ -222,6 +227,11 @@ impl Workspace { ), } this.sync_cli_server(cx); + let bundle = cx.app_path().ok(); + this.updater.update(cx, |updater, cx| { + updater.set_services(UpdateServices::system(bundle), cx); + updater.start(cx); + }); this } @@ -290,6 +300,12 @@ impl Workspace { ); let session_panel = cx.new(|cx| SessionPanel::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 updater = cx.new(|cx| { + let mut updater = Updater::new(); + updater.set_automatic(automatic, cx); + updater + }); let forward_panel = cx.new(|cx| { ForwardPanel::new( store.clone(), @@ -352,7 +368,15 @@ impl Workspace { cx.observe_in(&settings, window, |this, settings, window, cx| { crate::settings::apply(settings.read(cx).settings(), window, cx); this.sync_cli_server(cx); + this.sync_updater(cx); }), + cx.subscribe_in( + &updater, + window, + |this, _, event: &UpdaterEvent, window, cx| { + this.on_updater_event(event, window, cx) + }, + ), cx.subscribe_in( &settings, window, @@ -442,6 +466,7 @@ impl Workspace { settings, cli_integration: cx.new(|_| CliIntegration::new(None)), cli_server: None, + updater, local_terminal_factory, remote_terminal_provider, sftp_provider, @@ -543,6 +568,10 @@ impl Workspace { &self.cli_integration } + pub fn updater(&self) -> &Entity { + &self.updater + } + /// Keep the start page and focus in step with the center: once its last /// tab closes, the page fills it and takes the focus the tab held, so /// window-level shortcuts keep a dispatch path. @@ -994,7 +1023,8 @@ impl Workspace { let integration = self.cli_integration.clone(); // Something may have been installed or removed since last time. integration.update(cx, |integration, cx| integration.refresh(cx)); - let panel = cx.new(|cx| SettingsPanel::new(store, integration, cx)); + let updater = self.updater.clone(); + let panel = cx.new(|cx| SettingsPanel::new(store, integration, updater, cx)); let subscription = cx.subscribe(&panel, |this, _, event: &SettingsPanelEvent, cx| { match event { SettingsPanelEvent::Activated => { @@ -2133,6 +2163,11 @@ impl Render for Workspace { .on_action(cx.listener(Self::on_set_file_size_format)) .on_action(cx.listener(Self::on_remove_agent_skill)) .on_action(cx.listener(Self::on_refresh_cli_integration)) + .on_action(cx.listener(Self::on_check_for_updates)) + .on_action(cx.listener(Self::on_download_update)) + .on_action(cx.listener(Self::on_show_update)) + .on_action(cx.listener(Self::on_restart_to_update)) + .on_action(cx.listener(Self::on_open_download_page)) .on_action(cx.listener(Self::on_copy_agent_skill)) .on_action(cx.listener(Self::on_close_settings)) .on_action(cx.listener(Self::on_close_active_tab)) @@ -2177,6 +2212,7 @@ impl Render for Workspace { .child(render_title_bar( sidebar, running_forwards, + self.updater.read(cx).snapshot().badge(), &self.focus_handle, cx, )) diff --git a/tests/ui.rs b/tests/ui.rs index f87b9b1..d9a25dd 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -12,14 +12,15 @@ use gpui_kit::{ MouseMoveEvent, TestAppContext, WindowHandle, point, px, size, }; +use semver::Version; use shellrs::app::{ - CenterTab, ClearTerminal, CloseScope, CloseTabs, CollapseAllGroups, ConnectGroup, - ConnectSession, CopyCredentialPublicKey, CopySessionHost, CopySessionId, DeleteCredential, - DeleteForward, DeleteGroup, DeleteSession, DisconnectSession, DisconnectTerminal, - EditCredential, EditForward, EditSession, ExpandAllGroups, FindInTerminal, FindNextInTerminal, - FindPreviousInTerminal, FocusSearch, InstallCliCommand, NewLocalTerminal, NewSessionInGroup, - OpenExplorer, ReconnectTerminal, RemoveAgentSkill, RenameGroup, RenameTerminal, StartForward, - StopForward, ToggleSessionPanel, + CenterTab, CheckForUpdates, ClearTerminal, CloseScope, CloseTabs, CollapseAllGroups, + ConnectGroup, ConnectSession, CopyCredentialPublicKey, CopySessionHost, CopySessionId, + DeleteCredential, DeleteForward, DeleteGroup, DeleteSession, DisconnectSession, + DisconnectTerminal, EditCredential, EditForward, EditSession, ExpandAllGroups, FindInTerminal, + FindNextInTerminal, FindPreviousInTerminal, FocusSearch, InstallCliCommand, NewLocalTerminal, + NewSessionInGroup, OpenExplorer, ReconnectTerminal, RemoveAgentSkill, RenameGroup, + RenameTerminal, StartForward, StopForward, ToggleSessionPanel, }; use shellrs::cli::{AgentKind, IntegrationPaths}; use shellrs::connection::{ @@ -48,6 +49,10 @@ use shellrs::terminal::{ TerminalLifecycle, TerminalSize, TerminalTransport, TerminalTransportCommand, TerminalTransportEvent, TerminalTransportFactory, }; +use shellrs::update::{ + Channel, InstallKind, Installer, Relaunch, Release, Staged, TrustedKeys, Unsupported, + UpdateError, UpdateFeed, UpdateServices, +}; use shellrs::workspace::Workspace; /// Seeded session ids, in insertion order (see `SessionStore::seed`). @@ -9415,3 +9420,530 @@ async fn a_segment_that_cannot_be_chosen_stays_unchosen(cx: &mut TestAppContext) assert!(window.try_find("credential-key-text").is_none()); }); } + +// --------------------------------------------------------------------------- +// 在线升级 +// --------------------------------------------------------------------------- + +/// The version the update tests run as, whatever `Cargo.toml` says. +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. +struct FakeUpdateFeed { + envelope: Mutex, UpdateError>>, + package: Vec, + fetches: AtomicUsize, + downloads: AtomicUsize, +} + +impl UpdateFeed for FakeUpdateFeed { + fn fetch(&self, _: Channel) -> Result, UpdateError> { + self.fetches.fetch_add(1, Ordering::SeqCst); + self.envelope.lock().unwrap().clone() + } + + fn download( + &self, + _: &[String], + size: u64, + dest: &std::path::Path, + progress: &mut dyn FnMut(u64), + cancel: &std::sync::atomic::AtomicBool, + ) -> Result<(), UpdateError> { + self.downloads.fetch_add(1, Ordering::SeqCst); + if cancel.load(Ordering::SeqCst) { + return Err(UpdateError::Cancelled); + } + progress(size / 2); + std::fs::write(dest, &self.package)?; + progress(size); + Ok(()) + } +} + +/// Records what it was asked to stage and apply; installs nothing. +struct FakeInstaller { + kind: InstallKind, + staged: Mutex>, + applied: Mutex>, +} + +impl FakeInstaller { + fn new(kind: InstallKind) -> Arc { + Arc::new(Self { + kind, + staged: Mutex::default(), + applied: Mutex::default(), + }) + } + + fn applied(&self) -> Vec { + self.applied.lock().unwrap().clone() + } +} + +impl Installer for FakeInstaller { + fn kind(&self) -> &InstallKind { + &self.kind + } + + fn stage(&self, package: &std::path::Path, release: &Release) -> Result { + self.staged.lock().unwrap().push(release.version.clone()); + Ok(Staged { + version: release.version.clone(), + path: package.to_path_buf(), + }) + } + + fn apply(&self, _: &Staged, relaunch: bool) -> Result { + self.applied.lock().unwrap().push(relaunch); + Ok(if relaunch { + Relaunch::Restart(INSTALLED_BUNDLE.into()) + } else { + Relaunch::Nothing + }) + } + + fn clean_up(&self) {} +} + +/// A copy of ShellRS that can install updates itself. +fn installable() -> InstallKind { + InstallKind::MacBundle { + bundle: INSTALLED_BUNDLE.into(), + } +} + +/// The update server's side of a test: what it serves, and the fakes the +/// updater was given. +struct UpdateFixture { + feed: Arc, + installer: Arc, + folder: tempfile::TempDir, +} + +impl UpdateFixture { + fn fetches(&self) -> usize { + self.feed.fetches.load(Ordering::SeqCst) + } + + fn downloads(&self) -> usize { + self.feed.downloads.load(Ordering::SeqCst) + } +} + +/// A stable manifest offering `version` for this platform, signed by `pair`. +fn signed_manifest(pair: &minisign::KeyPair, version: &str, package: &[u8]) -> Vec { + use sha2::Digest as _; + let platform = shellrs::update::platform::platform_key(); + let sha256: String = sha2::Sha256::digest(package) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + let manifest = serde_json::json!({ + "schema": 1, + "channel": "stable", + "version": version, + "published_at": "2026-10-20T08:00:00Z", + "notes": "### 新增\n\n- 在线升级:新版本在后台下载好后,标题栏会提示。", + "assets": { + platform: { + "urls": ["https://dl.shellrs.com/releases/package"], + "size": package.len(), + "sha256": sha256, + } + }, + "installers": { platform: "https://dl.shellrs.com/releases/installer" }, + }) + .to_string(); + let signature = minisign::sign( + Some(&pair.pk), + &pair.sk, + std::io::Cursor::new(manifest.as_bytes()), + Some(&format!("shellrs-manifest stable {version}")), + None, + ) + .unwrap() + .into_string(); + serde_json::to_vec(&serde_json::json!({ "manifest": manifest, "signature": signature })) + .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. +fn serve_updates( + cx: &mut TestAppContext, + workspace: &Entity, + offered: Result<&str, UpdateError>, + 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 feed = Arc::new(FakeUpdateFeed { + envelope: Mutex::new(envelope), + package, + fetches: AtomicUsize::new(0), + downloads: AtomicUsize::new(0), + }); + let installer = FakeInstaller::new(kind); + let folder = tempfile::tempdir().unwrap(); + let services = UpdateServices { + feed: feed.clone(), + installer: installer.clone(), + keys: TrustedKeys::new([pair.pk.to_base64().as_str()]), + channel: Some(Channel::Stable), + current: Version::parse(RUNNING_VERSION).unwrap(), + folder: folder.path().to_path_buf(), + draw: 0.5, + }; + workspace.update(cx, |workspace, cx| { + workspace + .updater() + .update(cx, |updater, cx| updater.set_services(services, cx)); + }); + UpdateFixture { + feed, + installer, + folder, + } +} + +/// Open 设置 › 关于. +fn open_about_settings(cx: &mut TestAppContext, handle: WindowHandle) { + in_frame(cx, handle, |window, cx| window.click("open-settings", cx)); + in_frame(cx, handle, |window, cx| { + window.within("settings").click("0-3", cx) + }); +} + +async fn wait_for_update_status( + cx: &mut TestAppContext, + handle: WindowHandle, + wanted: impl Fn(&str) -> bool, +) { + cx.wait_for(handle.into(), Duration::from_secs(3), |window, _| { + window + .try_find("update-status") + .and_then(|status| status.label().map(&wanted)) + .unwrap_or(false) + }) + .await; +} + +fn update_status(cx: &mut TestAppContext, handle: WindowHandle) -> String { + in_frame(cx, handle, |window, _| { + window + .find("update-status") + .label() + .unwrap_or_default() + .to_string() + }) +} + +fn set_automatic_updates(cx: &mut TestAppContext, workspace: &Entity, on: bool) { + workspace.update(cx, |workspace, cx| { + workspace.settings().update(cx, |settings, cx| { + settings.update(|settings| settings.update.automatic = on, cx) + }); + }); + cx.run_until_parked(); +} + +#[gpui_kit::test] +fn a_development_build_does_not_check(cx: &mut TestAppContext) { + let (handle, _) = open_workspace(cx); + open_about_settings(cx, handle); + in_frame(cx, handle, |window, _| { + assert_eq!( + window.find("update-status").label(), + 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!(window.try_find("update-available").is_none()); + }); +} + +#[gpui_kit::test] +async fn checking_by_hand_says_when_shellrs_is_up_to_date(cx: &mut TestAppContext) { + 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), "尚未检查更新"); + + in_frame(cx, handle, |window, cx| { + window.click("check-for-updates", cx) + }); + wait_for_update_status(cx, handle, |status| { + status.starts_with("已是最新版本 · 上次检查 ") + }) + .await; + assert_eq!(fixture.fetches(), 1); + assert_eq!(fixture.downloads(), 0); + in_frame(cx, handle, |window, _| { + assert!(window.try_find("update-available").is_none()); + }); +} + +#[gpui_kit::test] +async fn a_found_update_downloads_by_itself_and_the_title_bar_offers_it(cx: &mut TestAppContext) { + let (handle, workspace) = open_workspace(cx); + let fixture = serve_updates(cx, &workspace, Ok("0.2.0"), installable()); + open_about_settings(cx, handle); + + in_frame(cx, handle, |window, cx| { + window.click("check-for-updates", cx) + }); + cx.wait_for(handle.into(), Duration::from_secs(3), |window, _| { + window + .try_find("update-available") + .is_some_and(|button| button.label() == Some("新版本 0.2.0 已就绪")) + }) + .await; + assert_eq!(fixture.downloads(), 1); + assert_eq!( + *fixture.installer.staged.lock().unwrap(), + [Version::new(0, 2, 0)] + ); + assert_eq!( + update_status(cx, handle), + "0.2.0 已就绪,退出或重启 ShellRS 时安装" + ); + in_frame(cx, handle, |window, _| { + assert!(window.try_find("show-update").is_some()); + // The release notes are on the page too. + assert!(window.try_find("download-update").is_none()); + }); +} + +#[gpui_kit::test] +async fn with_automatic_updates_off_a_found_update_waits_for_download(cx: &mut TestAppContext) { + let (handle, workspace) = open_workspace(cx); + set_automatic_updates(cx, &workspace, false); + let fixture = serve_updates(cx, &workspace, Ok("0.2.0"), installable()); + open_about_settings(cx, handle); + + in_frame(cx, handle, |window, cx| { + window.click("check-for-updates", cx) + }); + 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; + assert_eq!(fixture.downloads(), 1); +} + +#[gpui_kit::test] +async fn the_update_dialog_shows_the_notes_and_restarts_into_the_new_version( + cx: &mut TestAppContext, +) { + let (handle, workspace) = open_workspace(cx); + let fixture = serve_updates(cx, &workspace, Ok("0.2.0"), installable()); + in_frame(cx, handle, |window, cx| { + window.dispatch_action(Box::new(CheckForUpdates), cx) + }); + cx.wait_for(handle.into(), Duration::from_secs(3), |window, _| { + window.try_find("update-available").is_some() + }) + .await; + + in_frame(cx, handle, |window, cx| { + window.click("update-available", cx) + }); + in_frame(cx, handle, |window, _| { + let notes = window + .find("update-notes") + .label() + .unwrap_or_default() + .to_string(); + assert!(notes.contains("在线升级"), "{notes}"); + assert!(window.try_find("restart-to-update").is_some()); + }); + + let restart = cx.expect_restart(); + in_frame(cx, handle, |window, cx| { + window.click("restart-to-update", cx) + }); + let (path, _) = restart.await.expect("restarted"); + assert_eq!(path, Some(INSTALLED_BUNDLE.into())); + assert_eq!(fixture.installer.applied(), [true]); + // The note the new version reads to say it was updated. + let note = std::fs::read_to_string(fixture.folder.path().join("applied.json")).unwrap(); + assert!( + note.contains("0.2.0") && note.contains(RUNNING_VERSION), + "{note}" + ); + + // Already installed: quitting does not install it again. + cx.update(|cx| cx.shutdown()); + assert_eq!(fixture.installer.applied(), [true]); +} + +#[gpui_kit::test] +async fn the_update_dialog_says_what_restarting_interrupts(cx: &mut TestAppContext) { + let (handle, workspace) = open_workspace(cx); + let _fixture = serve_updates(cx, &workspace, Ok("0.2.0"), installable()); + in_frame(cx, handle, |window, cx| { + window.dispatch_action(Box::new(CheckForUpdates), cx) + }); + cx.wait_for(handle.into(), Duration::from_secs(3), |window, _| { + window.try_find("update-available").is_some() + }) + .await; + in_frame(cx, handle, |window, cx| { + window.click("update-available", cx) + }); + // web-01 and staging-api start out connected, each with a terminal. + cx.wait_for(handle.into(), Duration::from_secs(3), |window, _| { + window + .try_find("update-restart-note") + .and_then(|note| note.label().map(|label| label.contains("2 个远程终端"))) + .unwrap_or(false) + }) + .await; + + // The status bar is outside the dialog, under its backdrop. + in_frame(cx, handle, |window, cx| { + window.click("status-connection", cx) + }); + in_frame(cx, handle, |window, _| { + assert!( + window.try_find("update-dialog").is_some(), + "a click beside the update dialog closed it" + ); + }); +} + +#[gpui_kit::test] +async fn a_ready_update_is_installed_on_quit_once(cx: &mut TestAppContext) { + let (handle, workspace) = open_workspace(cx); + let fixture = serve_updates(cx, &workspace, Ok("0.2.0"), installable()); + workspace.update(cx, |workspace, cx| { + workspace.updater().update(cx, |updater, cx| { + updater.start(cx); + updater.check(cx); + }); + }); + cx.wait_for(handle.into(), Duration::from_secs(3), |window, _| { + window.try_find("update-available").is_some() + }) + .await; + assert!(fixture.installer.applied().is_empty()); + + cx.update(|cx| cx.shutdown()); + assert_eq!(fixture.installer.applied(), [false]); + cx.update(|cx| cx.shutdown()); + assert_eq!(fixture.installer.applied(), [false]); +} + +#[gpui_kit::test] +async fn an_unsupported_install_offers_the_download_page(cx: &mut TestAppContext) { + let (handle, workspace) = open_workspace(cx); + let fixture = serve_updates( + cx, + &workspace, + Ok("0.2.0"), + InstallKind::Unsupported(Unsupported::NotAppImage), + ); + open_about_settings(cx, handle); + in_frame(cx, handle, |window, cx| { + window.click("check-for-updates", cx) + }); + wait_for_update_status(cx, handle, |status| { + status == "发现新版本 0.2.0。这份 ShellRS 不是 AppImage,请用安装它的方式更新" + }) + .await; + assert_eq!(fixture.downloads(), 0); + in_frame(cx, handle, |window, _| { + assert!(window.try_find("open-download-page").is_some()); + assert!(window.try_find("download-update").is_none()); + assert_eq!( + window.find("update-available").label(), + Some("新版本 0.2.0 可用") + ); + }); +} + +#[gpui_kit::test] +async fn a_failed_check_says_why_on_the_about_page(cx: &mut TestAppContext) { + let (handle, workspace) = open_workspace(cx); + let fixture = serve_updates(cx, &workspace, Err(UpdateError::Http(503)), installable()); + open_about_settings(cx, handle); + in_frame(cx, handle, |window, cx| { + window.click("check-for-updates", cx) + }); + wait_for_update_status(cx, handle, |status| { + status == "检查失败:更新服务器返回 HTTP 503" + }) + .await; + assert_eq!(fixture.fetches(), 1); + in_frame(cx, handle, |window, cx| { + assert!(window.try_find("update-available").is_none()); + assert!(window.notifications(cx).is_empty()); + }); +} + +#[gpui_kit::test] +async fn turning_automatic_updates_off_stops_the_schedule(cx: &mut TestAppContext) { + let (handle, workspace) = open_workspace(cx); + let fixture = serve_updates(cx, &workspace, Ok(RUNNING_VERSION), installable()); + workspace.update(cx, |workspace, cx| { + workspace + .updater() + .update(cx, |updater, cx| updater.start(cx)); + }); + cx.run_until_parked(); + assert_eq!(fixture.fetches(), 0, "nothing before the first wait"); + + cx.executor().advance_clock(Duration::from_secs(31)); + open_about_settings(cx, handle); + wait_for_update_status(cx, handle, |status| status.starts_with("已是最新版本")).await; + assert_eq!(fixture.fetches(), 1); + + set_automatic_updates(cx, &workspace, false); + cx.executor() + .advance_clock(Duration::from_secs(7 * 60 * 60)); + cx.run_until_parked(); + assert_eq!(fixture.fetches(), 1); + + set_automatic_updates(cx, &workspace, true); + cx.executor() + .advance_clock(Duration::from_secs(7 * 60 * 60)); + cx.wait_for(handle.into(), Duration::from_secs(3), |_, _| { + fixture.fetches() == 2 + }) + .await; +} + +#[gpui_kit::test] +async fn the_first_start_after_an_update_says_so(cx: &mut TestAppContext) { + let (handle, workspace) = open_workspace(cx); + let fixture = serve_updates(cx, &workspace, Ok(RUNNING_VERSION), installable()); + std::fs::write( + fixture.folder.path().join("applied.json"), + r#"{"from":"0.0.9","to":"0.1.0"}"#, + ) + .unwrap(); + workspace.update(cx, |workspace, cx| { + workspace + .updater() + .update(cx, |updater, cx| updater.start(cx)); + }); + cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| { + window.notifications(cx).len() == 1 + }) + .await; + assert!(!fixture.folder.path().join("applied.json").exists()); +}