From f0690fe6b23f47b170bebe5d7fb741e96e08d1a8 Mon Sep 17 00:00:00 2001 From: Xuz Date: Thu, 1 Oct 2026 12:33:01 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9C=A8=E7=BA=BF=E5=8D=87=E7=BA=A7=EF=BC=9A?= =?UTF-8?q?=E5=90=8E=E5=8F=B0=E6=A3=80=E6=9F=A5=E3=80=81=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E5=B9=B6=E5=9C=A8=E9=87=8D=E5=90=AF=E6=88=96=E9=80=80=E5=87=BA?= =?UTF-8?q?=E6=97=B6=E5=AE=89=E8=A3=85=E6=96=B0=E7=89=88=E6=9C=AC=EF=BC=8C?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E6=B5=81=E6=B0=B4=E7=BA=BF=E4=B8=8E=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 update 模块:从 dl.shellrs.com 取已签名的更新清单(minisign,签名绑定通道和版本), 按大小和 SHA-256 校验安装包;下载可续传、可换镜像,走系统代理 - 按安装方式安装:macOS 整包交换并核对开发者签名,Windows 静默运行安装程序,Linux 覆盖 AppImage; 开发构建和无法自更新的安装只提示前往下载页 - 设置新增「关于」:版本信息、检查更新、下载进度和「自动检查并下载更新」开关; 新版本就绪后标题栏右上角提示,更新对话框写明重启会中断什么;退出时自动安装,新版本启动后提示已更新 - 数据库拒绝打开更新版本建的库,迁移已有的库之前先备份成 shellrs.db.v<旧版本>.bak - 新增 release workflow 和 packaging 脚本:universal macOS 包签名公证、Inno Setup 安装程序、 AppImage、上传 R2、生成并签名更新清单;新增 CHANGELOG.md - 应用图标放在 assets/logo;开发模式下 macOS Dock 显示 logo,Windows exe 嵌入图标 - 修正 AppImage 里 shellrs 命令链接到临时挂载点;启动时刷新已安装但过期的 Agent skill - 新增清单、验签、下载、各平台安装器和界面的单元测试与 UI 测试 --- .github/workflows/release.yml | 320 ++++++++++++ .github/workflows/windows.yml | 5 +- AGENTS.md | 1 + CHANGELOG.md | 18 + CLAUDE.md | 20 +- Cargo.lock | 257 +++++++++- Cargo.toml | 34 ++ README.md | 46 +- assets/logo/design-prompt.txt | 22 + assets/logo/shellrs.icns | Bin 0 -> 123920 bytes assets/logo/shellrs.ico | Bin 0 -> 32032 bytes assets/logo/shellrs.png | Bin 0 -> 45661 bytes assets/logo/shellrs.svg | 10 + build.rs | 19 + packaging/linux/appimage.sh | 40 ++ packaging/linux/shellrs.desktop | 9 + packaging/macos/Info.plist.in | 42 ++ packaging/macos/bundle.sh | 47 ++ packaging/make-manifest.sh | 100 ++++ packaging/manifest.example.json | 38 ++ packaging/windows/shellrs.iss | 73 +++ src/app/actions.rs | 10 + src/app/app_icon.rs | 34 ++ src/app/assets.rs | 3 +- src/app/mod.rs | 6 +- src/app/paths.rs | 12 + src/cli/install.rs | 51 +- src/cli/integration.rs | 8 +- src/cli/mod.rs | 2 +- src/lib.rs | 6 +- src/main.rs | 4 + src/session/database.rs | 107 +++- src/settings/mod.rs | 1 + src/settings/model.rs | 27 + src/settings/settings_panel.rs | 211 +++++++- src/update/build_info.rs | 99 ++++ src/update/error.rs | 54 ++ src/update/feed.rs | 582 ++++++++++++++++++++++ src/update/install.rs | 419 ++++++++++++++++ src/update/install_linux.rs | 127 +++++ src/update/install_macos.rs | 477 ++++++++++++++++++ src/update/install_windows.rs | 159 ++++++ src/update/manifest.rs | 297 +++++++++++ src/update/mod.rs | 39 ++ src/update/platform.rs | 65 +++ src/update/status.rs | 337 +++++++++++++ src/update/update_dialog.rs | 229 +++++++++ src/update/updater.rs | 853 ++++++++++++++++++++++++++++++++ src/update/verify.rs | 376 ++++++++++++++ src/workspace/mod.rs | 1 + src/workspace/title_bar.rs | 32 +- src/workspace/updates.rs | 147 ++++++ src/workspace/workspace_view.rs | 46 +- tests/ui.rs | 546 +++++++++++++++++++- 54 files changed, 6425 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 assets/logo/design-prompt.txt create mode 100644 assets/logo/shellrs.icns create mode 100644 assets/logo/shellrs.ico create mode 100644 assets/logo/shellrs.png create mode 100644 assets/logo/shellrs.svg create mode 100644 build.rs create mode 100755 packaging/linux/appimage.sh create mode 100644 packaging/linux/shellrs.desktop create mode 100644 packaging/macos/Info.plist.in create mode 100755 packaging/macos/bundle.sh create mode 100755 packaging/make-manifest.sh create mode 100644 packaging/manifest.example.json create mode 100644 packaging/windows/shellrs.iss create mode 100644 src/app/app_icon.rs create mode 100644 src/update/build_info.rs create mode 100644 src/update/error.rs create mode 100644 src/update/feed.rs create mode 100644 src/update/install.rs create mode 100644 src/update/install_linux.rs create mode 100644 src/update/install_macos.rs create mode 100644 src/update/install_windows.rs create mode 100644 src/update/manifest.rs create mode 100644 src/update/mod.rs create mode 100644 src/update/platform.rs create mode 100644 src/update/status.rs create mode 100644 src/update/update_dialog.rs create mode 100644 src/update/updater.rs create mode 100644 src/update/verify.rs create mode 100644 src/workspace/updates.rs 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 0000000000000000000000000000000000000000..b51cf32960008378e9663b0b9c896e2b94a8b5d0 GIT binary patch literal 123920 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�c(DxVwGAR9?%>F6jxnPlq_;u^MJr#pu@8qWhP7; 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 literal 0 HcmV?d00001 diff --git a/assets/logo/shellrs.ico b/assets/logo/shellrs.ico new file mode 100644 index 0000000000000000000000000000000000000000..be5959f0df9b928c5a36f05260acbc59590b60d4 GIT binary patch literal 32032 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 +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()); +}