在线升级:后台检查、下载并在重启或退出时安装新版本,发布流水线与应用图标

- 新增 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 测试
This commit is contained in:
Xuz committed 2026-10-01 12:33:01 +08:00
1 parent e60275dfde
commit f0690fe6b2
54 files changed
+6425 -43

No files matched your search

+320
View File
@@ -0,0 +1,320 @@
# A release: push a tag `v<version>` 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"
+3 -2
View File
@@ -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
+1
View File
@@ -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`)。
关键流程与不变量:
+18
View File
@@ -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 用已保存的主机执行命令、传输文件。
- 在线升级:在后台检查并下载新版本,下好后标题栏右上角提示,重启或退出时安装。
+16 -4
View File
@@ -10,7 +10,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP
会话和分组是**真实持久化**的,存在一个 SQLite 文件里(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)。本地终端(`portable-pty` + `alacritty_terminal`)和 SSH 远程连接(`russh`)也都是真的。密码与私钥口令存在系统钥匙串里(`keyring`),**数据库里永远不出现秘密**。SFTP 双栏浏览、上传、下载和文件操作(删除、重命名、属性、新建)也已接入真实文件系统,上传和下载都支持断点续传,并按 WinSCP 的方式排队(每个 SFTP 标签一个传输队列);目录同步尚未实现。SSH 端口转发(本地 `-L`、远程 `-R`、动态 `-D`)也是真的,规则和会话存在同一个库里。凭据(密码 / 密钥 / SSH Agent)也存在这个库里(密码和口令照旧只进钥匙串,粘贴或生成的私钥是数据目录 `keys/` 下的文件),会话可以「使用凭据」而不是自己填登录信息。会话还有「连接方式」:直接连接、经过别的会话做 SSH 跳板(可多级)、或经由 HTTP / SOCKS5 代理,终端、SFTP、端口转发、外部 CLI 和测试连接都照它走。详见 `README.md`。
已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一会话可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按会话、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或会话;分组支持任意层级嵌套,会话也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的会话一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的三个图标(主机 / 端口转发 / 凭据)切换;一条规则经由一个已保存的会话,启动时自己建一条 SSH 连接,不计入会话的连接状态,会话「断开连接」不停它;删除会话(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。凭据是侧栏的第三个列表(密码 / 密钥 / SSH Agent 三种),**凭据带用户名**:会话「使用凭据」时不再填用户名,改凭据的用户名所有用它的会话一起变;会话对话框的「认证方式」是三选一:「密码」(只有用户名和密码,密码留空就每次连接时询问,不悄悄试密钥)、「使用凭据」和「无密码」(只有用户名:依次试服务器免认证、SSH Agent 和 `~/.ssh` 的默认私钥,服务器要求密码时直接报错、不询问;密钥之后再要验证码照样问);会话自己不再有私钥文件,要用私钥就建密钥凭据(版本 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`),连接时直Line truncated
已确认的产品决定:WinSCP 式双栏文件浏览器是「SFTP」Dock 标签页,和终端一样每次「打开 SFTP」都新开一个(同一会话可以开多个,各自独立连接),操作逻辑对标 WinSCP Commander(两行工具栏、路径标签、WinSCP 的列、列表右键菜单、高亮多选;面板顶上不再有「用户@主机:端口」一行;本地侧与远程侧对称,本地删除进废纸篓;书签按会话、按本地 / 远程各一份持久化);传输进行中还能再发起传输,新批次在 SFTP 标签底部的「传输队列」里排队依次执行(WinSCP 的队列面板,不做前台进度对话框);暂不做 Dock 布局持久化、过滤、查找文件和目录树;首次启动是空库,不预置任何分组或会话;分组支持任意层级嵌套,会话也可以不属于任何分组(渲染在树的根层级);删除分组会连同其子分组和里面的会话一起删,删除前确认并写明数量。端口转发不是 Dock 标签页,而是侧栏的另一个列表,由标题栏「ShellRS」后面的三个图标(主机 / 端口转发 / 凭据)切换;一条规则经由一个已保存的会话,启动时自己建一条 SSH 连接,不计入会话的连接状态,会话「断开连接」不停它;删除会话(或含它的分组)连同经由它的规则一起删,确认框写明数量;规则可设「启动 ShellRS 时自动开启」,「创建」只保存不启动;新建规则时「经由主机」留空(不预选当前标签的主机,也不预选第一台),由用户自己选;断线后按 1、3、10 秒自动重连三次,重连不弹任何问答;配置对话框用排成一行的三张类型卡片、实时示意图、一句话说明和一直循环的流向动画说明三种转发,示意图和那句话里的服务器按角色叫「SSH 服务器」,不写所选主机的名称;暂不做转发的外部 CLI 接口、规则排序和监听端口 0。凭据是侧栏的第三个列表(密码 / 密钥 / SSH Agent 三种),**凭据带用户名**:会话「使用凭据」时不再填用户名,改凭据的用户名所有用它的会话一起变;会话对话框的「认证方式」是三选一:「密码」(只有用户名和密码,密码留空就每次连接时询问,不悄悄试密钥)、「使用凭据」和「无密码」(只有用户名:依次试服务器免认证、SSH Agent 和 `~/.ssh` 的默认私钥,服务器要求密码时直接报错、不询问;密钥之后再要验证码照样问);会话自己不再有私钥文件,要用私钥就建密钥凭据(版本 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`),连接时直Line truncated
做 UI 之前先读 `gpui-kit` 与 `gpui-kit-design-guides` 两个技能(其中的 Coding Guides 和 Design Guides 在本仓库是规范,不是参考)。不要凭记忆臆造 gpui-kit API:以 `~/.cargo/registry/src/*/gpui-component-0.6.1/`、`gpui-base-0.6.1/` 的源码或 `https://gpui-kit.com/component/<name>.md` 为准。
@@ -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<GroupId>`,`Session.os` 是 `Option<HostOs>`,`Session.credential` 是 `Option<CredentialId>`,`Session.route` 是连接方式 `Route::{Direct, Jump(Vec<Option<SessionId>>), 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:<id>` / `s:<id>`;`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 校验写 `<data_dir>/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`,写 `<data_dir>/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`,写 `<data_dir>/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: <minisign 签名>}`,一个文件、一次请求,不会出现清单和签名缓存不同步;`Manifest::offer(当前版本, 平台 key, 本机抽签)` 给出 `Offer::{UpToDate, Update(Release), Manual}`。格式只加字段:不认识的字段忽略,`schema` 比 `SCHEMA` 大的清单只读出版本、让用户去下载页。平台 key 是 `macos-aarch64` / `macos-x86_64` / `windows-x86_64` / `linux-x86_64`(`platform.rs`),macOS 两个 key 指向同一个 universal 包。
- `verify.rs`:`open_envelope` 先验签再解析,签名的 trusted comment 必须是 `shellrs-manifest <通道> <版本>` 且和清单一致(防拿旧清单冒充新版本、拿 beta 清单冒充 stable);安装包靠清单里的大小和 SHA-256(`verify_file`)。debug 构建额外信任 `SHELLRS_UPDATE_PUBLIC_KEY`,release 构建只信编进去的。
- `feed.rs`:`UpdateFeed` trait(阻塞,只搬字节,所以假 feed 绕不过验签)与真实的 `HttpFeed`(reqwest blocking,默认特性里的 rustls + 系统代理:从 Finder / 开始菜单启动的进程没有 `HTTPS_PROXY`);下载写 `.part`、用 Range 续传、`urls` 依次回退,`timeout` 在阻塞客户端里是每次读的超时,所以等于停滞判断;超时被包成「解码响应体出错」,`read_error` 从内层的 `reqwest::Error` 认出来。debug 构建可用 `SHELLRS_UPDATE_URL` 指向本地服务器。客户端在工作线程上按任务新建(阻塞客户端自带 runtime,不能在 async 上下文里建或丢)。
- `install.rs`:纯函数 `detect` 决定 `InstallKind`(`MacBundle` / `WindowsSetup` / `AppImage` / `Unsupported(..)`:开发构建、App Translocation、DMG 里运行、没写权限、不是安装程序装的、不是 AppImage);`Installer` trait 的 `stage` 在工作线程上做慢的部分,`apply` 在 UI 线程上只改名或准备一个进程,返回 `Relaunch::{Restart(path), Hand(Launch), Nothing}`。平台实现 `install_macos.rs`(`ditto` 解到 app 旁边同一卷上的 `.ShellRS.app.update-<版本>`,`codesign` 核对 Team ID,两次改名交换,第二步失败就换回去)、`install_windows.rs`(隐藏的 PowerShell 等 ShellRS 退出后静默运行新版本的安装程序,再按需启动 ShellRS)、`install_linux.rs`(复制到 `$APPIMAGE` 旁边,一次改名覆盖;重启用 `$APPIMAGE`,current_exe 是会消失的挂载点)都在 `cfg(test)` 下也编译,Mac 上能跑它们的测试;外部命令在 `MacTools` 之类的接缝后面。
- `updater.rs`:`Updater` 实体,阶段 `Phase::{Off, Idle, Checking, UpToDate, Available, Downloading, Verifying, Ready, Installing, Failed{stage, error}}`。每个任务一个 `shellrs-update` 线程,只在有任务时每 100 ms 轮询事件(同端口转发),新任务取消旧任务、丢掉旧任务的事件。`start()`(只在生产路径调用):报告上一次更新(读 `applied.json`)、30 秒后清理并第一次检查、之后每 6 小时(带抖动)、`on_app_quit` 时把 Ready 的版本装好。下载进度只 `notify`(设置页和对话框观察 `Updater`),阶段变化才发 `UpdaterEvent::Changed`,工作区只订阅它来重画标题栏。
- `status.rs`(纯函数):设置页的状态行、标题栏按钮 `UpdateBadge`、哪些按钮可用,以及 `restart_note(RestartImpact)`。`update_dialog.rs`:更新对话框。
- `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<SessionStore>`:生产环境由 `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-<agent>` 与 `skill-status-<agent>`(agent 为 `agents` / `codex` / `claude` / `opencode`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)`(`aria_label` 是「32 ms」或「超时」)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、会话行的 tooltip `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 个文件到 …Line truncated
UI 测试(`tests/ui.rs`)在无头窗口里渲染真实的 `Workspace`(夹具传入 `SettingsStore::in_memory()`,不读写真实的设置文件),通过 `gpui_kit::test::TestWindowExt`(`click`、`double_click`、`input`、`press`、`find`、`within`)驱动。`Workspace::new` 要求外部传入 `Entity<SessionStore>`:生产环境由 `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-<agent>` 与 `skill-status-<agent>`(agent 为 `agents` / `codex` / `claude` / `opencode`,状态行的 `aria_label` 是显示的文字)、`check-cli-integration`、`copy-agent-skill`;设置项同理,下拉按钮是 `group-<组>` › `item-<项>` › `btn`,`label` 是当前选项的文字)、终端字体预览 `terminal-font-preview`(`aria_label` 形如「Menlo 13 px,行高 1.54」)、`("close-explorer", id)`、标签标题 `("terminal-tab", id)` / `("explorer-tab", explorer_id)` / `("local-terminal-tab", id)`(`aria_label` 是标签文字)、`("terminal-tab-os", id)` 与 `("explorer-tab-os", explorer_id)`、`test-connection`、`("terminal-latency", id)`(`aria_label` 是「32 ms」或「超时」)、`terminal-find`(查找输入框)、`terminal-find-count`(`aria_label` 是「3/12」「无结果」或「N 项」,查询为空或搜索还在去抖时不存在)、`terminal-find-previous` / `-next` / `-close`、`tab-name`(重命名标签对话框)、会话行的 tooltip `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 Line truncated
Generated
+252 -5
View File
@@ -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"
+34
View File
@@ -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.
+44 -2
View File
@@ -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 <ID> <远程路径> <本地路径>
**尚未实现:目录同步、前台传输进度对话框、队列排序、过滤、查找文件、目录树和 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)。
+22
View File
@@ -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 像素。
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-labelledby="title desc">
<title id="title">ShellRS — centered lettering preview</title>
<desc id="desc">Original approved RS contours traced to vectors. Foreground group centered within tile; common bottom at y=796.</desc>
<rect x="64" y="64" width="896" height="896" rx="176" fill="#082F63"/>
<g id="centered-mark" transform="translate(2.235799 -48)">
<path id="terminal" d="M192 340 L468 560 L192 780" fill="none" stroke="#FFF9EB" stroke-width="128" stroke-linecap="round" stroke-linejoin="round"/>
<rect id="cursor" x="556" y="624" width="54" height="220" rx="27" fill="#00D9C5"/>
<path id="letters" fill="#FFF9EB" fill-rule="evenodd" d="M840.5570 704.0286 L832.0306 704.7053 L823.5042 706.3731 L820.0937 707.3839 L815.8305 709.0366 L810.7146 711.5192 L806.4514 714.0701 L802.1883 717.5490 L799.4819 720.2757 L797.4334 722.8336 L795.7694 725.3915 L793.6099 729.6547 L792.2704 733.0653 L791.0868 738.1811 L790.5990 742.4443 L790.5413 747.5601 L790.7635 750.1180 L791.7002 755.2339 L792.4558 757.7918 L794.3592 762.0550 L796.9964 766.3182 L799.6303 769.4577 L802.1883 771.8218 L805.5988 774.5461 L808.5691 776.5498 L815.8305 780.2746 L822.6516 783.0124 L843.9675 790.3191 L849.0833 792.5025 L852.4939 794.4116 L854.1992 795.6653 L856.1929 797.8658 L856.9959 799.5710 L857.3406 802.9816 L857.0343 805.5395 L856.3500 807.2448 L855.2772 808.9501 L854.1992 810.1125 L852.4939 811.5435 L849.0833 813.2610 L845.6728 814.2227 L839.7043 814.9011 L836.2938 814.8548 L832.0306 814.4166 L826.9148 813.3169 L822.6516 811.7738 L818.3884 809.8504 L814.1385 807.2448 L808.1567 802.7252 L805.5988 801.8951 L803.8935 801.9264 L802.1883 802.3942 L800.4830 803.3971 L799.1660 804.6869 L788.3227 819.1817 L787.7227 820.8870 L787.6062 822.5923 L788.2000 825.1502 L789.3987 826.8035 L796.2198 832.2166 L800.4830 834.7954 L807.3041 838.1734 L814.1252 840.6524 L820.9463 842.3956 L826.0621 843.2675 L831.1780 843.6589 L835.4411 844.0000 L839.7043 843.9669 L848.2307 843.3549 L853.3465 842.5103 L860.1676 840.7675 L865.2835 838.9890 L872.1046 835.5885 L874.6625 833.8692 L879.7783 829.6732 L882.3866 826.8555 L885.0715 823.4449 L887.5226 819.1817 L889.3776 814.9185 L890.4223 811.5080 L891.3656 806.3921 L891.5284 799.5710 L891.2722 794.4552 L890.3791 790.1920 L889.5345 787.6341 L887.6063 783.3709 L886.0805 780.8130 L883.4668 777.4025 L879.7783 773.7502 L875.5151 770.3861 L867.8414 766.1816 L856.7571 761.8914 L839.7043 756.5976 L832.8832 753.9576 L831.1780 753.0884 L828.6200 751.4797 L826.3680 749.2654 L825.7118 748.4128 L824.9427 746.7075 L824.7073 745.0022 L824.7202 742.4443 L825.1086 740.7390 L825.8338 739.0337 L828.0465 736.4758 L829.4727 735.3398 L832.8832 733.7190 L836.2938 732.8784 L839.7043 732.5214 L843.1149 732.4138 L847.3781 732.6438 L849.9360 732.9085 L854.1992 733.7941 L859.3150 735.4061 L863.3650 737.3285 L870.3993 741.2306 L872.9572 741.5687 L875.5151 740.9282 L876.3678 740.4199 L877.7431 739.0337 L887.7516 724.5389 L888.5664 722.8336 L888.8099 721.1283 L888.7196 719.4231 L887.9358 717.7178 L886.5994 716.2542 L884.8941 714.9159 L878.9257 711.5689 L872.9572 709.0028 L865.2835 706.5861 L861.0203 705.5923 L855.0518 704.6492 L846.5254 704.0000 Z M671.7346 707.3358 L670.2331 708.3388 L669.1767 709.4809 L668.1227 711.7493 L668.0006 712.6020 L668.0000 833.6766 L668.2078 835.3818 L668.9052 837.0871 L670.8820 838.9687 L673.4399 839.9149 L694.7559 839.9270 L696.1266 839.6450 L698.1664 838.4701 L699.4174 837.0871 L700.2151 835.3818 L700.3392 834.5292 L700.4254 797.0131 L700.7243 796.6365 L701.5770 796.5234 L713.5139 796.4421 L714.3665 796.6669 L714.6869 797.0131 L739.8139 836.2345 L740.7983 837.4204 L742.6358 838.7924 L745.0615 839.8042 L756.9984 839.9557 L772.3459 839.9086 L774.0512 839.0725 L775.3151 837.9398 L776.3634 836.2345 L776.8081 834.5292 L776.6441 832.8239 L775.8796 831.1187 L749.4882 791.8973 L749.1454 791.0447 L754.3676 788.4868 L758.6726 785.9288 L762.9669 782.6042 L764.8512 780.8130 L768.4362 776.5498 L770.1243 773.9919 L772.4055 769.7287 L774.0738 765.4655 L775.1644 761.2023 L776.0290 755.2339 L776.0817 749.2654 L775.8429 745.0022 L775.0365 739.8864 L774.0512 735.9451 L772.3748 731.3600 L770.0694 727.0968 L767.6365 723.6863 L764.6722 720.4655 L760.4090 716.6653 L757.8511 714.8908 L753.5879 712.4428 L749.3247 710.6256 L740.7983 708.1383 L734.8299 707.2194 L728.0087 706.9237 L674.2926 706.9272 Z M701.5770 734.7052 L700.7243 734.8952 L700.3621 735.6232 L700.3383 768.0234 L700.7243 769.1438 L702.4296 769.3109 L726.3035 769.2572 L730.5667 768.4278 L733.1246 767.5406 L735.6825 766.1302 L737.5993 764.6129 L739.1455 762.9076 L740.2873 761.2023 L741.8388 757.7918 L742.6996 752.6760 L742.7503 750.9707 L742.0800 745.8548 L741.1045 743.2969 L739.4596 740.7390 L737.3878 738.7072 L735.4242 737.3285 L733.1246 736.2431 L730.5667 735.4203 L727.1561 734.8472 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

+19
View File
@@ -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}");
}
}
}
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Put a built `shellrs` binary into an AppImage.
#
# packaging/linux/appimage.sh <binary> <version> <out-dir>
#
# 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"
+9
View File
@@ -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
+42
View File
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!--
The bundle's identity. CFBundleIdentifier and the signing team must never
change: the keychain grants each saved password to them, so a new
identifier would have macOS ask about every password again.
bundle.sh fills in @VERSION@ and @BUILD@ (a number that only goes up).
-->
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>zh_CN</string>
<key>CFBundleDisplayName</key>
<string>ShellRS</string>
<key>CFBundleExecutable</key>
<string>shellrs</string>
<key>CFBundleIconFile</key>
<string>ShellRS</string>
<key>CFBundleIdentifier</key>
<string>com.shellrs.ShellRS</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>ShellRS</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>@VERSION@</string>
<key>CFBundleVersion</key>
<string>@BUILD@</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.developer-tools</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSLocalNetworkUsageDescription</key>
<string>ShellRS 需要访问本地网络,才能连接局域网中的主机。</string>
<key>NSHumanReadableCopyright</key>
<string>© ShellRS</string>
</dict>
</plist>
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Put a built `shellrs` binary into ShellRS.app.
#
# packaging/macos/bundle.sh <binary> <version> <build-number> <out-dir>
#
# 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"
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# Write the signed update manifest of one release for one channel.
#
# packaging/make-manifest.sh <version> <channel> <notes.md> <dist-dir> <out.json>
#
# <dist-dir> 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 <channel> <version>` 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"
+38
View File
@@ -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"
}
}
+73
View File
@@ -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=<version>
#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}"
+10
View File
@@ -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,
]
);
+34
View File
@@ -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) {}
+2 -1
View File
@@ -46,7 +46,8 @@ icon_assets!(
KeyRound,
RectangleEllipsis,
FileKey,
UserRoundKey
UserRoundKey,
CircleArrowUp
]
);
+5 -1
View File
@@ -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::*;
+12
View File
@@ -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<PathBuf> {
let dir = data_dir();
+50 -1
View File
@@ -106,7 +106,10 @@ impl IntegrationPaths {
/// Windows gets a folder of ShellRS's own, added to the user's PATH.
pub fn system() -> Option<Self> {
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>) -> 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<usize> {
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() {
+5 -3
View File
@@ -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>) {
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)
})
+1 -1
View File
@@ -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};
+4 -2
View File
@@ -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
+4
View File
@@ -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
+105 -2
View File
@@ -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 `<file>.v<version>.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<String> = 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 (
+1
View File
@@ -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};
+27
View File
@@ -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 {
+207 -4
View File
@@ -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<SettingsStore>,
/// What of the external CLI is installed, for 外部 CLI.
integration: Entity<CliIntegration>,
/// Where updating stands, for 关于.
updater: Entity<Updater>,
/// 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<WeakEntity<TabGroup>>,
_subscriptions: [Subscription; 2],
_subscriptions: [Subscription; 3],
_font_scan: Task<()>,
}
@@ -55,11 +61,13 @@ impl SettingsPanel {
pub fn new(
store: Entity<SettingsStore>,
integration: Entity<CliIntegration>,
updater: Entity<Updater>,
cx: &mut Context<Self>,
) -> 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<SettingGroup>;
/// 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<SettingGroup> {
]
}
/// 关于: which ShellRS this is, and updating it.
fn about_groups(panel: &SettingsPanel, cx: &App) -> Vec<SettingGroup> {
let snapshot = panel.updater.read(cx).snapshot();
let (reader, writer) = (panel.store.clone(), panel.store.clone());
let notes = snapshot
.release
.as_ref()
.filter(|release| !release.notes.trim().is_empty())
.map(|release| (release.version.to_string(), release.notes.clone()));
let mut groups = vec![
SettingGroup::new().title("ShellRS").items([version_item()]),
SettingGroup::new().title("更新").items([
update_status_item(snapshot),
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<dyn Action>)> =
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<String>, status: Option<IntegrationStatus>) -> SettingItem {
SettingItem::render(move |_, _, cx| {
+99
View File
@@ -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<Self> {
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<Self> {
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);
}
}
+54
View File
@@ -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<std::io::Error> for UpdateError {
fn from(error: std::io::Error) -> Self {
Self::Disk(error.to_string())
}
}
+582
View File
@@ -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<Vec<u8>, 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<Client, UpdateError> {
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<Vec<u8>, 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<u64> {
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::<reqwest::Error>())
.is_some_and(reqwest::Error::is_timeout);
if timed_out {
return UpdateError::Stalled;
}
match error
.get_ref()
.and_then(|inner| inner.downcast_ref::<reqwest::Error>())
{
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<u8>),
/// The whole body whatever the range.
IgnoringRange(Vec<u8>),
/// The headers of the whole body, then only its first bytes.
CutAfter(Vec<u8>, usize),
/// The headers, then nothing.
Silence(Vec<u8>),
Status(u16),
}
/// A request as the server saw it.
#[derive(Clone, Debug)]
pub(crate) struct Seen {
pub path: String,
pub range: Option<String>,
pub user_agent: Option<String>,
}
/// A small HTTP/1.1 server on 127.0.0.1, one connection per request.
pub(crate) struct Server {
port: u16,
answers: Arc<Mutex<HashMap<String, Answer>>>,
seen: Arc<Mutex<Vec<Seen>>>,
}
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<Seen> {
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<HashMap<String, Answer>>, seen: &Mutex<Vec<Seen>>) {
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::<usize>().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<u8> {
(0..len).map(|i| (i % 251) as u8).collect()
}
fn download(
feed: &HttpFeed,
urls: &[String],
size: u64,
dest: &Path,
) -> (Result<(), UpdateError>, Vec<u64>) {
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());
}
}
+419
View File
@@ -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<OsString>,
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<Staged, UpdateError>;
/// 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<Relaunch, UpdateError>;
/// 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<Staged, UpdateError> {
Err(UpdateError::Install("这份 ShellRS 无法自动更新".into()))
}
fn apply(&self, _: &Staged, _: bool) -> Result<Relaunch, UpdateError> {
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<PathBuf>) -> Arc<dyn Installer> {
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()))
);
}
}
+127
View File
@@ -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<Staged, UpdateError> {
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<Relaunch, UpdateError> {
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()));
}
}
+477
View File
@@ -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-<version>`, so
//! that putting it in place is two renames on one volume: the running
//! bundle moves aside to `.ShellRS.app.old-<id>` 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<String, UpdateError>;
/// 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<String, UpdateError> {
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<String, UpdateError> {
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<dyn MacTools>,
/// The Developer ID team a new bundle must be signed by: the one this
/// release was signed by.
team: Option<String>,
}
impl MacInstaller {
pub fn new(bundle: PathBuf, tools: Arc<dyn MacTools>) -> 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<PathBuf, UpdateError> {
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<Staged, UpdateError> {
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<Relaunch, UpdateError> {
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<String>,
asked_for: Mutex<Vec<Option<String>>>,
}
impl FakeTools {
fn new(version: &str, team: Option<&str>) -> Arc<Self> {
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<String, UpdateError> {
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<String> {
let mut names: Vec<String> = 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#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleExecutable</key><string>shellrs</string>
<key>CFBundleIdentifier</key><string>com.shellrs.test</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>{version}</string>
</dict></plist>"#
),
)
.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"]);
}
}
+159
View File
@@ -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<Staged, UpdateError> {
Ok(Staged {
version: release.version.clone(),
path: package.to_path_buf(),
})
}
fn apply(&self, staged: &Staged, relaunch: bool) -> Result<Relaunch, UpdateError> {
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(_))
));
}
}
+297
View File
@@ -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<String>,
/// 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<Version>,
/// 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<String>,
/// The package each platform updates itself with, by platform key.
#[serde(default)]
pub assets: BTreeMap<String, Asset>,
/// What a person downloads to install by hand, by platform key.
#[serde(default)]
pub installers: BTreeMap<String, String>,
}
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<String>,
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<String>,
pub notes: String,
pub notes_url: Option<String>,
pub asset: Asset,
pub installer: Option<String>,
}
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<String>,
},
}
/// 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<Option<Manifest>, 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}");
}
}
}
+39
View File
@@ -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};
+65
View File
@@ -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());
}
}
+337
View File
@@ -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<String> {
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<UpdateBadge> {
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<String> {
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<String> {
let parts: Vec<String> = 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);
}
}
+229
View File
@@ -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<dyn Fn(&App) -> RestartImpact>;
/// The dialog's body. It observes the updater so the progress moves while
/// the dialog is open.
pub struct UpdateDialog {
updater: Entity<Updater>,
impact: ImpactCounter,
_observer: Subscription,
}
impl UpdateDialog {
fn new(updater: Entity<Updater>, impact: ImpactCounter, cx: &mut Context<Self>) -> Self {
Self {
_observer: cx.observe(&updater, |_, _, cx| cx.notify()),
updater,
impact,
}
}
}
impl Render for UpdateDialog {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> 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<String> {
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<Updater>,
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<dyn Action>)> =
if snapshot.phase == Phase::Ready {
Some(("restart-to-update", "重启更新", Box::new(RestartToUpdate)))
} else if snapshot.needs_download_page() {
Some((
"open-download-page",
"前往下载页",
Box::new(OpenDownloadPage),
))
} else if snapshot.can_download() {
let label = if matches!(snapshot.phase, Phase::Failed { .. }) {
"重试"
} else {
"下载"
};
Some(("download-update", label, Box::new(DownloadUpdate)))
} else {
None
};
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);
})
}))
}
+853
View File
@@ -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<dyn UpdateFeed>,
pub installer: Arc<dyn Installer>,
pub keys: TrustedKeys,
/// `None` for a development build, which never looks.
pub channel: Option<Channel>,
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<PathBuf>) -> 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::<f64>().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<String>,
}
/// 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<Release>,
pub manual: Option<ManualUpdate>,
pub checked_at: Option<DateTime<Local>>,
/// Why this copy cannot install updates itself, when it cannot.
pub unsupported: Option<Unsupported>,
pub automatic: bool,
/// 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<Opened, UpdateError>),
Progress(u64),
Verifying,
Staged(Result<Staged, UpdateError>),
}
struct Job {
events: Receiver<WorkerEvent>,
cancel: Arc<AtomicBool>,
}
pub struct Updater {
services: Option<UpdateServices>,
phase: Phase,
release: Option<Release>,
manual: Option<ManualUpdate>,
staged: Option<Staged>,
checked_at: Option<DateTime<Local>>,
automatic: bool,
download_failures: u32,
job: Option<Job>,
polling: bool,
_schedule: Option<Task<()>>,
_quit: Option<Subscription>,
}
impl EventEmitter<UpdaterEvent> 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>) {
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>) {
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<Unsupported> {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
if let Some(Relaunch::Hand(launch)) = self.apply(false, cx) {
hand_over(launch);
}
}
fn apply(&mut self, relaunch: bool, cx: &mut Context<Self>) -> Option<Relaunch> {
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(&note)
.ok()
.map(|bytes| fs::write(&note_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(&note_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<Self>) {
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::<Applied>(&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<Self>) {
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<Self>,
job: impl FnOnce(&Sender<WorkerEvent>, &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<Self>) {
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<Self>) {
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<Self>) {
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<Opened, UpdateError>, cx: &mut Context<Self>) {
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<Self>) {
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<WorkerEvent>,
cancel: &AtomicBool,
) -> Result<Staged, UpdateError> {
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<Option<Launch>> = 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<String> = 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")
);
}
}
+376
View File
@@ -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 <channel> <version>`, 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<PublicKey>);
impl TrustedKeys {
/// Keys in minisign's base64 form, the second line of a `.pub` file.
pub fn new<'a>(keys: impl IntoIterator<Item = &'a str>) -> 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<Opened, UpdateError> {
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<Version> {
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<u8> {
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 [&current, &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"<html>", 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(&notes, "### 新增\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(&notes)
.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());
}
}
+1
View File
@@ -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};
+29 -3
View File
@@ -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<SidebarMode>,
forwards: usize,
update: Option<UpdateBadge>,
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()
+147
View File
@@ -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>,
) {
self.updater.update(cx, |updater, cx| updater.check(cx));
}
pub(super) fn on_download_update(
&mut self,
_: &DownloadUpdate,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.updater.update(cx, |updater, cx| updater.download(cx));
}
pub(super) fn on_show_update(
&mut self,
_: &ShowUpdate,
window: &mut Window,
cx: &mut Context<Self>,
) {
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>,
) {
self.updater.update(cx, |updater, cx| updater.restart(cx));
}
pub(super) fn on_open_download_page(
&mut self,
_: &OpenDownloadPage,
_: &mut Window,
cx: &mut Context<Self>,
) {
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<Self>,
) {
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(),
}
}
}
+41 -5
View File
@@ -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<ForwardManager>,
/// The start page the dock skin shows while the center has no tab.
recent: Entity<RecentSessions>,
terminals: HashMap<RemoteTerminalId, Entity<TerminalPanel>>,
pub(super) terminals: HashMap<RemoteTerminalId, Entity<TerminalPanel>>,
/// SFTP tabs; a session can have several, like terminals.
explorers: HashMap<ExplorerId, Entity<ExplorerPanel>>,
local_terminals: HashMap<LocalTerminalId, Entity<LocalTerminalPanel>>,
pub(super) explorers: HashMap<ExplorerId, Entity<ExplorerPanel>>,
pub(super) local_terminals: HashMap<LocalTerminalId, Entity<LocalTerminalPanel>>,
/// The settings tab, while it is open. There is only ever one.
settings_tab: Option<Entity<SettingsPanel>>,
/// 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<CliServer>,
/// 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<Updater>,
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<Subscription>,
}
@@ -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<Updater> {
&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,
))
+539 -7
View File
@@ -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<Result<Vec<u8>, UpdateError>>,
package: Vec<u8>,
fetches: AtomicUsize,
downloads: AtomicUsize,
}
impl UpdateFeed for FakeUpdateFeed {
fn fetch(&self, _: Channel) -> Result<Vec<u8>, 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<Vec<Version>>,
applied: Mutex<Vec<bool>>,
}
impl FakeInstaller {
fn new(kind: InstallKind) -> Arc<Self> {
Arc::new(Self {
kind,
staged: Mutex::default(),
applied: Mutex::default(),
})
}
fn applied(&self) -> Vec<bool> {
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<Staged, UpdateError> {
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<Relaunch, UpdateError> {
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<FakeUpdateFeed>,
installer: Arc<FakeInstaller>,
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<u8> {
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<Workspace>,
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<Root>) {
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<Root>,
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<Root>) -> 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<Workspace>, 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());
}