Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
552d55777d | ||
|
|
6674a47662 | ||
|
|
9799f88ade | ||
|
|
dc5adddd06 | ||
|
|
db0a0faf05 | ||
|
|
026e0d44d8 | ||
|
|
e91bab0d09 | ||
|
|
5ca6e53366 | ||
|
|
0aa1d4452d | ||
|
|
5fdfce239a | ||
|
|
3a9e72eab3 | ||
|
|
27822e7315 | ||
|
|
e1600ce05c | ||
|
|
5be3f56b0c | ||
|
|
eadbe6481a | ||
|
|
ecf9d92e47 | ||
|
|
63d1e4281c | ||
|
|
b834477bf4 | ||
|
|
65ea32acfb | ||
|
|
2df67bc639 | ||
|
|
e09adeefb7 | ||
|
|
3c937a9be2 |
No files matched your search
@@ -1,7 +1,7 @@
|
||||
# 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
|
||||
# macos per-architecture apps: 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
|
||||
@@ -82,9 +82,16 @@ jobs:
|
||||
|
||||
macos:
|
||||
needs: verify
|
||||
# 暂时只打 Windows 包,另行通知后再打开。
|
||||
if: false
|
||||
runs-on: macos-14
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [aarch64, x86_64]
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
ARCH: ${{ matrix.arch }}
|
||||
VERSION: ${{ needs.verify.outputs.version }}
|
||||
SHELLRS_UPDATE_CHANNEL: ${{ needs.verify.outputs.channel }}
|
||||
SHELLRS_COMMIT: ${{ github.sha }}
|
||||
@@ -92,18 +99,17 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: rustup toolchain install
|
||||
- run: rustup target add aarch64-apple-darwin x86_64-apple-darwin
|
||||
- run: rustup target add "$ARCH-apple-darwin"
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Build both halves and join them
|
||||
with:
|
||||
key: macos-${{ matrix.arch }}
|
||||
- name: Build the architecture-specific app
|
||||
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
|
||||
cargo build --release --locked --bin shellrs --target "$ARCH-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
|
||||
lipo "target/$ARCH-apple-darwin/release/shellrs" -verify_arch "${ARCH/aarch64/arm64}"
|
||||
packaging/macos/bundle.sh "target/$ARCH-apple-darwin/release/shellrs" "$VERSION" "$GITHUB_RUN_NUMBER" build
|
||||
- name: Sign
|
||||
env:
|
||||
CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE_P12 }}
|
||||
@@ -147,12 +153,12 @@ jobs:
|
||||
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"
|
||||
ditto -c -k --keepParent build/ShellRS.app "dist/ShellRS-$VERSION-macos-$ARCH.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"
|
||||
dmg="dist/ShellRS-$VERSION-macos-$ARCH.dmg"
|
||||
# hdiutil's own estimate of the image size once came out too small
|
||||
# for the app ("No space left on device"), so the size is given,
|
||||
# with room to spare; the compressed image is about half the app.
|
||||
@@ -171,7 +177,7 @@ jobs:
|
||||
spctl -a -vv -t install "$dmg"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos
|
||||
name: macos-${{ matrix.arch }}
|
||||
path: dist/*
|
||||
|
||||
windows:
|
||||
@@ -208,6 +214,8 @@ jobs:
|
||||
|
||||
linux:
|
||||
needs: verify
|
||||
# 暂时只打 Windows 包,另行通知后再打开。
|
||||
if: false
|
||||
# Ubuntu 22.04's glibc (2.35) is the oldest the AppImage runs on.
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 120
|
||||
@@ -265,7 +273,7 @@ jobs:
|
||||
- name: Collect
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cp artifacts/macos/* artifacts/windows/* artifacts/linux/* dist/
|
||||
cp artifacts/macos-aarch64/* artifacts/macos-x86_64/* artifacts/windows/* artifacts/linux/* dist/
|
||||
cp artifacts/notes/notes.md notes.md
|
||||
ls -l dist
|
||||
- name: Signing key
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# `shellrs exec --terminal` against the shells it types into: bash 4.2 to
|
||||
# 5.2, dash, busybox ash and a bash started as sh, each in a container on a
|
||||
# real terminal (`terminal::run::tests::shells`). The Macs this is
|
||||
# developed on run it only with Docker Desktop up.
|
||||
#
|
||||
# A push runs it when the run's code, the test helpers or the dependencies
|
||||
# change; run it by hand otherwise (Actions › shells › Run workflow).
|
||||
name: shells
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
paths:
|
||||
- "src/terminal/**"
|
||||
- "src/testing.rs"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "rust-toolchain.toml"
|
||||
- ".github/workflows/shells.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
shells:
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 90
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
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
|
||||
# Pulled first, so that no test waits on a download.
|
||||
- name: Images
|
||||
run: |
|
||||
for image in bash:4.2 bash:4.4 bash:5.0 bash:5.1 bash:5.2 \
|
||||
debian:stable-slim alpine:3 rockylinux:9; do
|
||||
docker pull --quiet "$image"
|
||||
done
|
||||
# rust-toolchain.toml picks the toolchain; this installs it with the
|
||||
# components it lists before the cache key is computed.
|
||||
- run: rustup toolchain install
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo test --lib terminal::run -- --include-ignored
|
||||
@@ -2,6 +2,83 @@
|
||||
|
||||
每个版本一节,标题写成 `## [版本号]`,可以在后面加日期。发布流程(`.github/workflows/release.yml`)把与 tag 同版本的那一节原样作为更新说明:显示在 GitHub Release 和官网的更新日志里(ShellRS 的更新对话框用「查看更新内容」打开官网的更新日志),用 Markdown 书写,面向使用者。
|
||||
|
||||
## [0.1.8]
|
||||
|
||||
### 新增
|
||||
|
||||
- 设置 › 终端新增「终端交互」:选中即复制,右键可设为粘贴、显示菜单或无动作。
|
||||
|
||||
### 改进
|
||||
|
||||
- macOS 安装包分为 Apple Silicon 和 Intel 两种,下载更小。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 ESXi 等主机「测试连接」报需要键盘交互式认证、保存的密码不起作用。
|
||||
- 修复启动后不久检查更新时,「重启并安装」报找不到文件。
|
||||
|
||||
## [0.1.8-beta.1]
|
||||
|
||||
### 新增
|
||||
|
||||
- 设置 › 终端新增「终端交互」:选中即复制,右键可设为粘贴、显示菜单或无动作。
|
||||
|
||||
### 改进
|
||||
|
||||
- macOS 安装包分为 Apple Silicon 和 Intel 两种,下载更小。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 ESXi 等主机「测试连接」报需要键盘交互式认证、保存的密码不起作用。
|
||||
|
||||
## [0.1.7]
|
||||
|
||||
### 新增
|
||||
|
||||
- Windows 上 SFTP 本地目录列表列出各个驱动器,可切换到其他盘。
|
||||
|
||||
### 改进
|
||||
|
||||
- SFTP 文件列表点列名即可排序。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复经部分堡垒机上传文件失败、编辑器保存时 SFTP 连接断开。
|
||||
- 修复 SFTP 换目录后拖宽的列恢复原宽。
|
||||
|
||||
## [0.1.7-beta.2]
|
||||
|
||||
### 新增
|
||||
|
||||
- Windows 上 SFTP 本地目录列表列出各个驱动器,可切换到其他盘。
|
||||
|
||||
### 改进
|
||||
|
||||
- SFTP 文件列表点列名即可排序。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复 SFTP 换目录后拖宽的列恢复原宽。
|
||||
|
||||
## [0.1.7-beta.1]
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复经部分堡垒机上传文件失败、编辑器保存时 SFTP 连接断开。
|
||||
|
||||
## [0.1.6]
|
||||
|
||||
### 新增
|
||||
|
||||
- 外部 CLI 新增 `shellrs exec --terminal`:在 ShellRS 已打开的终端里执行命令,适用于只允许登录一次的堡垒机。
|
||||
- 终端右侧显示滚动条,可拖动翻看历史输出。
|
||||
|
||||
## [0.1.6-beta.1]
|
||||
|
||||
### 新增
|
||||
|
||||
- 外部 CLI 新增 `shellrs exec --terminal`:在 ShellRS 已打开的终端里执行命令,适用于只允许登录一次的堡垒机。
|
||||
|
||||
## [0.1.5]
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -41,6 +41,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH
|
||||
- 秘密只写不读:JSON 里的 `password` / `passphrase` / `private_key` 进钥匙串或 `keys/`,任何输出都没有,`show` 只说有没有保存,ShellRS 保存的私钥连路径也不给。
|
||||
- 删除不确认:`hosts delete` 遇到有打开标签的主机报 `host_in_use`,`--force` 才关标签;删凭据时用它的主机改回自己登录。临时连接和外部连接只能 `show`。
|
||||
- `sync` 只从本机到主机,大小和修改时间都没变的跳过;`--delete` 不删 `.filepart` 和 `.shellrs-….backup`,本地读不了的目录不清理,拒绝同步到 `/`。
|
||||
- `exec --terminal`(`exec --json` 里 `"terminal": true`):给只允许登录一次的堡垒机(外部连接上 exec 重新登录会被拒,另开通道又会断会话)。把命令打进这台主机已打开的远程终端(右侧栏跟的那个,否则最新已连接的),所有远程终端都能用,不限外部连接。先发 ^C,**不做忙碌检测**(用户定的:用的人有基础,交给 Agent 的标签由用户负责;别加回光标行、密码提示之类的检查),只拒绝备用屏和同一终端已有一次运行。只支持 Linux 上的 bash、dash、busybox ash。输出经管道(没有颜色和分页器)、stderr 并入;`--json` 多一项 `context`(user、host、cwd、shell、interp)。
|
||||
- **开始页**:没有标签时显示「最近连接」。「快速连接」搜索保存的主机、回车连接;多选做好但关着(常量 `MULTIPLE`)。
|
||||
- **右侧栏**:窗口最右一列竖排的工具切换。
|
||||
- 显示规则:只跟着 SSH 远程终端出现,SFTP、本地终端、设置、开始页都不显示。它是工作区级的一份,开合和所选工具在所有终端间共用。默认收起,宽 320 px,只能往宽里拉。⌘⌥B / Ctrl+Alt+B 切换。
|
||||
@@ -69,6 +70,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH
|
||||
- 只认 UTF-8(不做 GBK),上限 5 MB,约 15 种语言语法高亮。
|
||||
- 远程文件原地改写:截断后写原文件,不走 `.filepart` 加改名。保存前核对,被改过就问「覆盖」。
|
||||
- **设置 › 应用 › 窗口**:「记住窗口大小」(含最大化)和「记住窗口位置」两个开关,默认都开,下次启动生效。屏幕不在了或窗口不在屏幕上就居中,比屏幕大就缩小;全屏不记。
|
||||
- **终端交互**:「选中复制」默认关;「右键行为」无动作 / 粘贴 / 显示菜单,默认显示菜单。
|
||||
- **终端通知**:只做程序请求的(OSC 9 / 777)和响铃,不做 OSC 133 长命令通知:那要每台服务器装 shell 集成,收益不抵成本。不在前台发系统通知,在前台而终端在别的标签发应用内通知;响铃在终端就在眼前时不提醒。
|
||||
- **主题**:「应用外观」(浅色 / 深色 / 跟随系统)决定深浅;「外观 › 主题」左右两栏里浅色、深色各选一套,按当前外观生效。组里第一项「界面跟随主题」默认关闭:界面是中性的灰(浅色是 gpui-kit 默认主题,深色见下条),只有终端按主题配色;打开后界面也按主题配色。用户试过默认跟随,觉得中性界面更合适,别改回去。
|
||||
- 默认深色(ShellRS Dark)不用 gpui-kit 的近黑,用参照微信深色的炭灰:底色 `#1e1e1f`,标题栏、标签栏、状态栏 `#2f2f30`,边框和悬停 `#3b3b3d`,文字 `#e0e0e4`。用户嫌近黑刺眼。次要文字比微信的 `#7b7b7f` 亮一些(`#929296`),保持对比度 ≥ 4.5。侧栏三个列表和「最近连接」的选中行同色(`selected_row_color`):浅色是悬停色三倍的对比,深色那样太刺眼,用加深后的悬停色,列表和表格的悬停只有它的一半(`DARK_HOVER_SHARE`)。两种外观下悬停和选中都要分得开。页面「重置」对卡片和开关各管各的。
|
||||
@@ -77,7 +79,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH
|
||||
- **键盘快捷键**:设置左栏单独一个分类,排在「关键字高亮」下面,分通用、标签、终端、编辑器四组。
|
||||
- ⌘= / ⌘- / ⌘0 在终端(和它的查找条)里调终端字号(每次 1 像素,经设置写进「终端 › 字号」,范围同那里的 10–28,`FONT_SIZE_RANGE`),在终端外缩放界面(基准字号 12–20,步长 2,不保存)。同主流终端,别改回去。
|
||||
- 只收主要功能(含新加的下一个 / 上一个 / 切换到标签 1…9),不收 SFTP 文件列表、图片预览的键和 Enter、方向键、Tab、Esc 这类固定键。
|
||||
- 每行:按键(点了录制「按下组合键」,Esc 或失焦放弃)、禁用、恢复默认。键要带 ⌘ / Ctrl / Alt 或是 F1–F24;撞上同一处的另一个快捷键或输入框等内置键就拒绝并提示占用者,不抢、不允许重复。
|
||||
- 每行:按键(点了录制「按下组合键」,Esc 或失焦放弃)、禁用、恢复默认。键要带 ⌘ / Ctrl / Alt,或是 F1–F24,或是 Shift 加 Insert / Delete / Home / End / Page Up / Page Down(方向键上方那六个,不挡打字;单独按仍给终端和列表)。撞上同一处的另一个快捷键或输入框等内置键就拒绝并提示占用者,不抢、不允许重复。
|
||||
- 存在 `settings.json` 的 `shortcuts`,只存和默认不同的(`null` 是禁用)。`Shortcut` 的 `id` 一经发布不能改。
|
||||
- **关键字高亮**:设置左栏单独一个分类,排在「终端」下面,三组:常规(总开关)、预览(当前终端主题下的示例输出)、规则(N)。规则存在 `settings.json`,所有终端共用。
|
||||
- 规则表在行里直接改,不弹对话框:拖动把手(一直显示,只有把手能拖)、启用、正则表达式、备注、颜色(gpui-kit `ColorPicker` 加 `#rrggbb` 输入框)、通知、删除(不确认)。规则都是正则,区分大小写;颜色是用户数据,任意 RGB,不跟主题;不做加粗。重叠时靠前的优先。
|
||||
@@ -97,6 +99,7 @@ cargo test --lib # 只跑单元测试
|
||||
cargo test --test ui # 只跑 UI 集成测试
|
||||
cargo test --test ui new_host -- --nocapture # 按名称片段跑单个 UI 测试
|
||||
cargo test --test cli_bin # 以子进程运行 shellrs-cli 的端到端测试(Windows CI 也跑)
|
||||
cargo test --lib terminal::run -- --include-ignored # exec --terminal 对 Docker 里的真实 shell(CI:shells.yml)
|
||||
cargo test --lib update:: # 在线升级;装了 minisign 和 jq 时还验证发布脚本签出的清单客户端认
|
||||
cargo clippy --all-targets -- -D warnings # 必须无警告
|
||||
cargo fmt --check
|
||||
@@ -278,6 +281,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- **每个标签独立连接、独立传输引擎。** 引擎一次只跑一批,其余在 `TransferQueue` 里排队,引擎的 `Idle` 推动队列前进;进度只归队首;停止的批次挡在队首,等用户继续或移出。
|
||||
- **SFTP 独立于终端。** 关闭终端不停传输;断开或删除主机会停止传输,并保留续传数据。
|
||||
- **下载严格按偏移顺序写 `.filepart`**,因为续传只信它的长度。
|
||||
- **只发 WinSCP 也发的请求**:用户的堡垒机对 `fsetstat` 回 Failure,收到 `fsync@openssh.com`(即使宣称支持)就断开整个会话。所以上传关闭文件后才按路径 `setstat` 设时间和权限;不用任何扩展:不发 fsync,覆盖也不用 `posix-rename@openssh.com`,而是先把旧文件改名成备份、发布后再删(不是原子的)。`protocol_tests` 的 `bastion_server` 对 `fsetstat` 和所有扩展请求都断开会话,守着这条。
|
||||
- **文件操作从不跟随符号链接**:删除只删链接本身,改权限跳过链接,传输原样创建链接。
|
||||
- **空闲时也要发现断网**:通道流包一层 `WatchedStream`。等待时只持有 `Weak`,不能攥着 `Arc<SftpClient>`,否则「断开」关不掉连接。
|
||||
- **选择归面板,不归表格。** `DataTable` 设成 `row_selectable(false)`,`FilePane` 持有按名称保存的 `Selection`。「名称」单元格才是项目,拖文件只从图标和文件名开始,其余地方按下拖动是框选。
|
||||
@@ -324,6 +328,12 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- 协议只加变体、不升 `PROTOCOL_VERSION`:旧应用对新请求回 serde 的「unknown variant」,客户端改说成 `version_mismatch`。服务端先读版本再解析请求。
|
||||
- `exec --json` 的输入输出都是 JSON,输出只用 ASCII(`ascii_json` 转义其余字符),出错也是 JSON:给 Windows PowerShell 用,绕开引号和代码页。不做 MCP:主流 Agent 都能执行命令,CLI 加 skill 已经够用。
|
||||
- UI 测试不监听真实套接字,`CliIntegration` 默认没有路径。
|
||||
- **`exec --terminal`**(`terminal/run.rs`,纯逻辑,状态都在 `TerminalRun` 的锁里):
|
||||
- 流程:引擎发 ^C → 请求线程等 500 ms(同时到的字节可能被 tty 随 ^C 冲掉)输入探测行 → 解析线程见到标记 P 就输入命令行 → 标记 B(上下文)、输出、标记 E(退出码)。时限在请求线程(`TerminalRun::drive`):P 5 秒(`terminal_busy`),B 15 秒(发 ^C 再报,可能被堡垒机命令过滤或复核拦下);执行中没有时限,每秒一个空 Stdout 帧,写失败就是调用方没了,发 ^C。
|
||||
- 标记是 OSC 6973 带每次随机的 NONCE(单独的参数,回显里的 `\033` 只是文字,伪造不了)。P 带 `readlink /proc/$PPID/exe`(去掉 ` (deleted)`;不用 `comm`,以 sh 名义启动的 bash 的 comm 是 sh)。E 由外层 sh 在管道之后打:`trap : INT` 挺过 ^C,状态经 fd 3 带出,被打断按 130 报,所以 ^C 后也一定有 E。
|
||||
- 命令按 `printf %b` 编码(控制字符、`'`、`\`、`!`、`"`、≥ 0x80 写成 `\0NNN`),打出的行里没有引号和控制字符,命令仍大体可读(审计、命令过滤看得见,所以不用 base64)。从不用 bracketed paste:命令行是在探测还在跑、tty 处于规范模式时预输入的,readline 那时没开 2004,一行最多 4095 字节,所以 bash、dash 上限 4000,busybox 的行编辑 1000。
|
||||
- 运行槽归运行时(`TerminalRuntime::run`):解析线程在 `notices.scan` 旁边喂字节,循环结束时报断开。服务端另有 `runs` 队列,工作区另起 100 ms 的 `serve_cli_runs`(不能排在逐条等改动的 `serve_cli` 后面)。
|
||||
- 真实 shell 的测试在 `run_tests.rs` 的 `shells`(`#[ignore]`,Docker 里 bash 4.2–5.2、dash、busybox、Rocky 的 sh),CI 是 `shells.yml`。
|
||||
- **Windows**:
|
||||
- 发布版是 GUI 子系统,命令改由 `shellrs-cli.exe` 承担;PATH 里放的是副本,加进用户 PATH。
|
||||
- `main` 先用 `link_arguments` 认堡垒机的链接参数,认出来就不走 CLI。
|
||||
@@ -347,7 +357,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
|
||||
- **终端颜色走全局量 `TerminalColors`**(`theme.rs`):`settings::apply` 按当前外观设成那一栏选中的主题,系统外观变化时也重设。终端格子、边距、选区、链接、颜色查询(OSC 4 / 10 / 11 / 12)和设置里的两个预览都从它取,不用界面主题的 `background` / `foreground`;查找高亮仍用界面的 `warning`。主题的 `key` 存进 `settings.json`,发布后不能改;读不出或深浅不符的 key 退回该外观的默认。
|
||||
- **界面配色由所选主题推出**(`settings/app_theme.rs`):从 gpui-kit 默认的浅色或深色主题出发重新着色。灰色按它在默认背景与文字之间的位置,换到该主题背景与文字之间;默认里用作可读文字的灰色(对比度 ≥ 4.5)至少保留该主题正文对比度的 80%(最高 4.5)。`base.*` 按名字换成对应的 ANSI 色,其余彩色按色相换;`.light` 和彩色的 `*foreground` 删掉,交给 gpui-kit 自己推。ShellRS Light(以及「界面跟随主题」关闭时的浅色,`TerminalThemeSettings::app_theme`)直接用 gpui-kit 的默认浅色主题;ShellRS Dark(以及关闭时的深色)是 gpui-kit 默认深色主题按 `SOFT_DARK` 把每级灰换成炭灰,彩色不动。`settings::apply` 按名字(`theme_name`)判断要不要重建,把结果放进 `Theme` 的浅色或深色槽再 `Theme::change`。
|
||||
- **链接**:正则找出的地址和 OSC 8 标出的都算,只认 http / https(`links.rs`);OSC 8 的格子优先于正则。一直画成蓝色(终端主题的 ANSI 蓝,悬停时亮蓝;界面主题的 `link` 和正文同色)加下划线,⌘ / Ctrl 单击才打开,普通单击照旧选文字。
|
||||
- **鼠标上报**(`mouse.rs` 是纯编码):按住 Shift 不上报,右键始终是自己的菜单。写入走 `engine.write`,不算用户输入。
|
||||
- **鼠标上报**(`mouse.rs` 是纯编码):按住 Shift 不上报,右键从不上报,按「右键行为」处理。写入走 `engine.write`,不算用户输入。
|
||||
- **关键字高亮只叠加在显示上**(`highlight.rs`)。`snapshot()` 每帧对可见行把折行接成逻辑行,用 `regex` crate 匹配,结果写进 `TerminalCell.highlight`,prepaint 改文字色和粗细;不存坐标,所以缩放重排、回滚都不用失效处理。
|
||||
- 不用 alacritty 的 `RegexSearch`(查找、链接用的那套):它的 DFA 编不了 `\b`,`^` 只在第一行生效,`$` 会碰到行尾补的空格。
|
||||
- 通知在解析线程上:按 `\n` 切开输出,在 LF **之前**读光标所在的逻辑行(这时 `\r` 和颜色都已生效),锁外匹配。备用屏和同步更新进行中不读。
|
||||
|
||||
Generated
+1
-1
@@ -7738,7 +7738,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "shellrs"
|
||||
version = "0.1.5"
|
||||
version = "0.1.8"
|
||||
dependencies = [
|
||||
"alacritty_terminal",
|
||||
"anyhow",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "shellrs"
|
||||
version = "0.1.5"
|
||||
version = "0.1.8"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
description = "SSH client and host manager: terminal, SFTP, port forwarding"
|
||||
@@ -113,6 +113,7 @@ windows-sys = { version = "0.61", features = [
|
||||
"Win32_System_IO",
|
||||
"Win32_System_Pipes",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_WindowsProgramming",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<a href="#license"><img src="https://img.shields.io/badge/license-GPL--3.0-blue" alt="License: GPL-3.0"></a>
|
||||
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey" alt="Platform: macOS | Windows | Linux">
|
||||
<img src="https://img.shields.io/badge/rust-1.98-orange" alt="Rust 1.98">
|
||||
<a href="https://linux.do"><img src="https://img.shields.io/badge/LINUX-DO-FFB003.svg?logo=data:image/svg%2bxml;base64,DQo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjEwMCIgaGVpZ2h0PSIxMDAiPjxwYXRoIGQ9Ik00Ni44Mi0uMDU1aDYuMjVxMjMuOTY5IDIuMDYyIDM4IDIxLjQyNmM1LjI1OCA3LjY3NiA4LjIxNSAxNi4xNTYgOC44NzUgMjUuNDV2Ni4yNXEtMi4wNjQgMjMuOTY4LTIxLjQzIDM4LTExLjUxMiA3Ljg4NS0yNS40NDUgOC44NzRoLTYuMjVxLTIzLjk3LTIuMDY0LTM4LjAwNC0yMS40M1EuOTcxIDY3LjA1Ni0uMDU0IDUzLjE4di02LjQ3M0MxLjM2MiAzMC43ODEgOC41MDMgMTguMTQ4IDIxLjM3IDguODE3IDI5LjA0NyAzLjU2MiAzNy41MjcuNjA0IDQ2LjgyMS0uMDU2IiBzdHlsZT0ic3Ryb2tlOm5vbmU7ZmlsbC1ydWxlOmV2ZW5vZGQ7ZmlsbDojZWNlY2VjO2ZpbGwtb3BhY2l0eToxIi8+PHBhdGggZD0iTTQ3LjI2NiAyLjk1N3EyMi41My0uNjUgMzcuNzc3IDE1LjczOGE0OS43IDQ5LjcgMCAwIDEgNi44NjcgMTAuMTU3cS00MS45NjQuMjIyLTgzLjkzIDAgOS43NS0xOC42MTYgMzAuMDI0LTI0LjM4N2E2MSA2MSAwIDAgMSA5LjI2Mi0xLjUwOCIgc3R5bGU9InN0cm9rZTpub25lO2ZpbGwtcnVsZTpldmVub2RkO2ZpbGw6IzE5MTkxOTtmaWxsLW9wYWNpdHk6MSIvPjxwYXRoIGQ9Ik03Ljk4IDcwLjkyNmMyNy45NzctLjAzNSA1NS45NTQgMCA4My45My4xMTNRODMuNDI2IDg3LjQ3MyA2Ni4xMyA5NC4wODZxLTE4LjgxIDYuNTQ0LTM2LjgzMi0xLjg5OC0xNC4yMDMtNy4wOS0yMS4zMTctMjEuMjYyIiBzdHlsZT0ic3Ryb2tlOm5vbmU7ZmlsbC1ydWxlOmV2ZW5vZGQ7ZmlsbDojZjlhZjAwO2ZpbGwtb3BhY2l0eToxIi8+PC9zdmc+" alt="LINUX DO"></a>
|
||||
</p>
|
||||
|
||||
<p align="center"><b>English</b> | <a href="README.zh-CN.md">简体中文</a></p>
|
||||
@@ -28,9 +29,6 @@ ShellRS brings Xshell-style host management and tabbed terminals, a WinSCP-style
|
||||
|
||||
Hosts, groups, credentials and forwarding rules live in a local SQLite database. Passwords and key passphrases go only into the system keychain (macOS Keychain, Windows Credential Manager, Linux Secret Service); the database never holds a secret. ShellRS also ships a `shellrs` command that lets AI agents such as Claude Code or Codex run commands and transfer files on your saved hosts without ever seeing a password.
|
||||
|
||||
> [!NOTE]
|
||||
> The interface is in English and Simplified Chinese. It follows the system language unless you choose one in Settings › Appearance › Language, and switches at once.
|
||||
|
||||
## Features
|
||||
|
||||
**Hosts**
|
||||
@@ -121,7 +119,8 @@ Download from [shellrs.com/download](https://shellrs.com/download), which offers
|
||||
|
||||
| System | Package | Notes |
|
||||
| --- | --- | --- |
|
||||
| macOS 11+ (Apple Silicon and Intel) | `ShellRS-<version>-macos-universal.dmg` | Signed and notarized. Open the DMG and drag ShellRS into Applications |
|
||||
| macOS 11+ (Apple Silicon) | `ShellRS-<version>-macos-aarch64.dmg` | Signed and notarized. Open the DMG and drag ShellRS into Applications |
|
||||
| macOS 11+ (Intel) | `ShellRS-<version>-macos-x86_64.dmg` | Signed and notarized. Open the DMG and drag ShellRS into Applications |
|
||||
| Windows x64 | `ShellRS-<version>-windows-x86_64-setup.exe` | Installs per user, no administrator rights needed. The installer is not signed yet; if SmartScreen stops it, choose "More info › Run anyway" |
|
||||
| Linux x64 | `ShellRS-<version>-linux-x86_64.AppImage` | `chmod +x` and run. Needs glibc 2.35 or newer (Ubuntu 22.04, Debian 12 and later) |
|
||||
|
||||
|
||||
+3
-4
@@ -13,6 +13,7 @@
|
||||
<a href="#许可证"><img src="https://img.shields.io/badge/license-GPL--3.0-blue" alt="License: GPL-3.0"></a>
|
||||
<img src="https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey" alt="Platform: macOS | Windows | Linux">
|
||||
<img src="https://img.shields.io/badge/rust-1.98-orange" alt="Rust 1.98">
|
||||
<a href="https://linux.do"><img src="https://img.shields.io/badge/LINUX-DO-FFB003.svg?logo=data:image/svg%2bxml;base64,DQo8c3ZnIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgd2lkdGg9IjEwMCIgaGVpZ2h0PSIxMDAiPjxwYXRoIGQ9Ik00Ni44Mi0uMDU1aDYuMjVxMjMuOTY5IDIuMDYyIDM4IDIxLjQyNmM1LjI1OCA3LjY3NiA4LjIxNSAxNi4xNTYgOC44NzUgMjUuNDV2Ni4yNXEtMi4wNjQgMjMuOTY4LTIxLjQzIDM4LTExLjUxMiA3Ljg4NS0yNS40NDUgOC44NzRoLTYuMjVxLTIzLjk3LTIuMDY0LTM4LjAwNC0yMS40M1EuOTcxIDY3LjA1Ni0uMDU0IDUzLjE4di02LjQ3M0MxLjM2MiAzMC43ODEgOC41MDMgMTguMTQ4IDIxLjM3IDguODE3IDI5LjA0NyAzLjU2MiAzNy41MjcuNjA0IDQ2LjgyMS0uMDU2IiBzdHlsZT0ic3Ryb2tlOm5vbmU7ZmlsbC1ydWxlOmV2ZW5vZGQ7ZmlsbDojZWNlY2VjO2ZpbGwtb3BhY2l0eToxIi8+PHBhdGggZD0iTTQ3LjI2NiAyLjk1N3EyMi41My0uNjUgMzcuNzc3IDE1LjczOGE0OS43IDQ5LjcgMCAwIDEgNi44NjcgMTAuMTU3cS00MS45NjQuMjIyLTgzLjkzIDAgOS43NS0xOC42MTYgMzAuMDI0LTI0LjM4N2E2MSA2MSAwIDAgMSA5LjI2Mi0xLjUwOCIgc3R5bGU9InN0cm9rZTpub25lO2ZpbGwtcnVsZTpldmVub2RkO2ZpbGw6IzE5MTkxOTtmaWxsLW9wYWNpdHk6MSIvPjxwYXRoIGQ9Ik03Ljk4IDcwLjkyNmMyNy45NzctLjAzNSA1NS45NTQgMCA4My45My4xMTNRODMuNDI2IDg3LjQ3MyA2Ni4xMyA5NC4wODZxLTE4LjgxIDYuNTQ0LTM2LjgzMi0xLjg5OC0xNC4yMDMtNy4wOS0yMS4zMTctMjEuMjYyIiBzdHlsZT0ic3Ryb2tlOm5vbmU7ZmlsbC1ydWxlOmV2ZW5vZGQ7ZmlsbDojZjlhZjAwO2ZpbGwtb3BhY2l0eToxIi8+PC9zdmc+" alt="LINUX DO"></a>
|
||||
</p>
|
||||
|
||||
<p align="center"><a href="README.md">English</a> | <b>简体中文</b></p>
|
||||
@@ -28,9 +29,6 @@ ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SF
|
||||
|
||||
主机、分组、凭据和转发规则保存在本地的 SQLite 数据库里。密码和私钥口令只存进系统钥匙串(macOS 钥匙串、Windows 凭据管理器、Linux Secret Service),数据库里没有任何秘密。ShellRS 还带一个 `shellrs` 命令,让 Claude Code、Codex 等 AI Agent 用你保存的主机执行命令、传输文件,不用把密码交给它们。
|
||||
|
||||
> [!NOTE]
|
||||
> 界面有简体中文和英文,默认跟随系统语言,可在「设置 › 外观 › 界面语言」里改,改了立即生效。
|
||||
|
||||
## 功能
|
||||
|
||||
**主机管理**
|
||||
@@ -121,7 +119,8 @@ ShellRS 把 Xshell 式的主机管理和多标签终端、WinSCP 式的双栏 SF
|
||||
|
||||
| 系统 | 安装包 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| macOS 11+(Apple Silicon 和 Intel) | `ShellRS-<版本>-macos-universal.dmg` | 已签名并经过 Apple 公证。打开 DMG,把 ShellRS 拖进「应用程序」 |
|
||||
| macOS 11+(Apple Silicon) | `ShellRS-<版本>-macos-aarch64.dmg` | 已签名并经过 Apple 公证。打开 DMG,把 ShellRS 拖进「应用程序」 |
|
||||
| macOS 11+(Intel) | `ShellRS-<版本>-macos-x86_64.dmg` | 已签名并经过 Apple 公证。打开 DMG,把 ShellRS 拖进「应用程序」 |
|
||||
| Windows x64 | `ShellRS-<版本>-windows-x86_64-setup.exe` | 按当前用户安装,不需要管理员权限。安装程序暂未签名,SmartScreen 拦下时点「更多信息 › 仍要运行」 |
|
||||
| Linux x64 | `ShellRS-<版本>-linux-x86_64.AppImage` | `chmod +x` 后直接运行,需要 glibc 2.35 以上(Ubuntu 22.04、Debian 12 及更新的版本) |
|
||||
|
||||
|
||||
+9
-7
@@ -16,7 +16,7 @@ Each item saved in the left sidebar is a host: an address, a port and a way to a
|
||||
|
||||
The host dialog’s “Authentication” has three options:
|
||||
|
||||
- **Password**: enter a username and password; the password is kept in the system keychain. Leave the password empty to be asked at each connection. Only the password is used: the SSH agent and private keys aren’t tried first.
|
||||
- **Password**: enter a username and password; the password is kept in the system keychain. Leave the password empty to be asked at each connection. Only the password is used: the SSH agent and private keys aren’t tried first. When the server takes passwords only through keyboard-interactive authentication (ESXi, for one), the saved password answers its single hidden question; other questions, such as a verification code, are still asked.
|
||||
- **Credential**: choose a saved credential. The username and the way to log in both come from the credential (see “Credentials” below), so the dialog no longer asks for a username or password. “Test connection” also uses the password saved in the credential. To log in with a private key, create a key credential.
|
||||
- **No password**: enter only the username. ShellRS tries logging in without authentication, then the SSH agent, then the default private keys in `~/.ssh` (`id_ed25519`, `id_ecdsa`, `id_rsa`), the same as the `ssh` command does by default. If the server asks for a password, the connection fails with the reason instead of asking; if the server asks for a verification code after the key, you’re still asked for it.
|
||||
|
||||
@@ -108,9 +108,10 @@ The “Settings” button at the bottom of the sidebar, or ⌘, (Ctrl+, on Windo
|
||||
- **Appearance → Interface → Appearance**: Dark, Light or System, System by default; System switches along with the system’s light and dark mode. The theme button in the title bar also changes this setting.
|
||||
- **Appearance → Theme**: the left column has light themes and the right column dark themes; choose one in each. The light appearance uses the one chosen on the left, the dark appearance the one on the right; with System they switch along with the system, and the same happens when you switch with the title bar’s theme button. By default only terminals take the chosen theme’s colors, and the interface stays a neutral gray (the dark one is an easy-on-the-eyes charcoal, not pure black). Turn on “Match interface to theme” at the top and the interface takes the theme’s colors too: the sidebar, tab bar, dialogs and so on use the theme’s background and text colors, and states such as success, warning and error use the theme’s green, yellow and red. Each card shows a sample in that theme’s colors, and the chosen one has a blue check mark at its top right. The defaults are ShellRS Light and ShellRS Dark, which match the interface. There are 10 built-in light themes and 10 dark ones: light ShellRS Light, Solarized Light, Tokyo Day, Catppuccin Latte, Flexoki Light, One Half Light, Gruvbox Light, Kanagawa Lotus, Rosé Pine Dawn, Everforest Light; dark ShellRS Dark, Solarized Dark, Tokyo Night, Catppuccin Mocha, Flexoki Dark, One Half Dark, Gruvbox Dark, Kanagawa Wave, Dracula, Nord. The light and dark versions of the same theme sit on the same row of the two columns. Changes apply at once to every open terminal, and the font and highlighting previews use the current theme too; when vim or another program asks the terminal for its background color, it gets the current theme’s. **Not implemented yet: custom themes, importing color schemes, and per-host themes.**
|
||||
- **Terminal → Font**: Font family (lists only the monospaced fonts installed on this computer; the default is the system’s monospaced font, Menlo on macOS), Font size (10–28 pixels, default 13) and Line height (1.0–2.0 times the font size, default 1.54, the same as the earlier 20-pixel line height). Changes apply at once to every open terminal; in a terminal, ⌘= / ⌘- (Ctrl on Windows and Linux) also change the font size step by step, and ⌘0 goes back to the default. The preview below lays out letters, digits, easily confused characters, symbols, Chinese, tab stops and a command line the way the terminal does. If the font in the settings file isn’t installed on this computer, the default font is used.
|
||||
- **Terminal → Interaction**: with “Copy on select” on, text you select with the mouse is copied to the clipboard when you let go, without pressing ⌘C; it’s off by default, and a click that selects nothing leaves the clipboard as it was. “Right-click” sets what right-clicking in a terminal does: Show menu (the default), Paste (pastes the clipboard, like ⌘V) or Do nothing. With either of the last two, the menu’s Copy, Paste, Find and Clear screen are still on their shortcuts, and Open SFTP and Reconnect are in the tab’s context menu. Changes apply at once to every open terminal.
|
||||
- **Highlighting**: the main switch, the preview and the rules table, which color text in terminals by rule and alert you on matches; see “Terminal”.
|
||||
- **Keyboard shortcuts**: the shortcuts of the main features, in four groups (General, Tabs, Terminal, Editor); each row is a feature and its keys. Click the keys to get “Press a key combination”, then press a new combination to use it; Esc or clicking elsewhere cancels. The two buttons at the end of the row are Disable (the row then shows “None”; click again to restore the default) and Restore default; the “Reset All” button at the top of the page restores them all. Changes take effect at once, and the keys shown in tooltips and context menus follow.
|
||||
- A combination needs ⌘, ⌥ or ⌃ (Ctrl or Alt on Windows and Linux), or must be one of F1–F12; for “Switch to tab 1…9”, press any digit with a modifier, and all nine change together.
|
||||
- A combination needs ⌘, ⌥ or ⌃ (Ctrl or Alt on Windows and Linux), or must be one of F1–F12, or Shift with Insert, Delete, Home, End, Page Up or Page Down (the six keys above the arrows, which do not type); for “Switch to tab 1…9”, press any digit with a modifier, and all nine change together.
|
||||
- A combination already taken by another shortcut in the same place, or by text fields and other built-in features, is refused, and you’re told what uses it; to use it anyway, change or disable that one first. Terminal and editor shortcuts can share keys with general shortcuts, and in the terminal or editor they take precedence (in a terminal, for example, ⌘K clears the screen).
|
||||
- Defaults (macOS / Windows and Linux): New host ⌘N / Ctrl+N, New group ⌘⇧N / Ctrl+Shift+N, Open settings ⌘, / Ctrl+,, Show or hide sidebar ⌘B / Ctrl+B, Show or hide right sidebar ⌘⌥B / Ctrl+Alt+B, Focus search ⌘K / Ctrl+K, Zoom in, Zoom out and Actual size ⌘= ⌘- ⌘0 / Ctrl+= Ctrl+- Ctrl+0, Quit ⌘Q / Ctrl+Q; New local terminal ⌘T / Ctrl+T, Close tab ⌘W / Ctrl+W, Next tab ⌘⇧] / Ctrl+Tab, Previous tab ⌘⇧[ / Ctrl+Shift+Tab, Switch to tab 1…9 ⌘1…⌘9 / Alt+1…9 (9 is the last tab); in the terminal, Copy, Paste, Find, Find next, Find previous and Clear screen ⌘C ⌘V ⌘F ⌘G ⌘⇧G ⌘K / Ctrl+Shift+C, Ctrl+Shift+V, Ctrl+Shift+F, F3, Shift+F3, Ctrl+Shift+K, and Bigger text, Smaller text and Default text size ⌘= ⌘- ⌘0 / Ctrl+= Ctrl+- Ctrl+0; in the editor, Save ⌘S / Ctrl+S. Switching tabs stays within the group of tabs the current tab is in, wrapping around at either end. In a terminal, ⌘= / ⌘- / ⌘0 change the terminal font size (1 pixel at a time, within the same 10–28 range as “Terminal › Font size”; saved in the settings and kept for the next launch); outside a terminal they zoom the whole interface (five steps, back to the default after a restart).
|
||||
- Shortcuts in the SFTP file list and the image preview, and fixed keys such as Enter, the arrow keys, Tab and Esc, aren’t changed here.
|
||||
@@ -191,12 +192,13 @@ Remote terminal tabs and SFTP tabs use the host’s system badge as their icon (
|
||||
|
||||
## Terminal
|
||||
|
||||
The terminal’s context menu has “Copy”, “Paste”, “Find…” and “Clear screen”; a remote terminal’s also has “Open SFTP”, “Reconnect” and “Disconnect”. “Disconnect” disconnects only this tab: the tab stays and can reconnect at any time, and other tabs and SFTP for the same host aren’t affected.
|
||||
The terminal’s context menu has “Copy”, “Paste”, “Find…” and “Clear screen”; a remote terminal’s also has “Open SFTP”, “Reconnect” and “Disconnect”. “Disconnect” disconnects only this tab: the tab stays and can reconnect at any time, and other tabs and SFTP for the same host aren’t affected. Settings › Terminal › Interaction can make right-click paste or do nothing instead, and can turn on “Copy on select”.
|
||||
|
||||
- **Scrollback**: each terminal keeps the last 10,000 lines. Scroll up with the wheel or trackpad and a scrollbar appears on the right (as in the app’s other lists, following the system’s “Show scroll bars” setting); drag it, or click the track to jump there. Full-screen programs such as vim, less and top have no scrollback and show no scrollbar.
|
||||
- **Find** (⌘F, or Ctrl+Shift+F on Windows and Linux): opens the find bar at the top right of the terminal and searches the screen and the scrollback for the literal text. An all-lowercase query ignores case; one with capital letters matches case. All matches are highlighted, the current one darker, and the find bar shows “current/total”. Enter or ⌘G (F3) goes to the next match (newer output), Shift+Enter or ⌘⇧G (Shift+F3) to the previous one (older output), wrapping around at the ends. Esc closes it. If text is selected when it opens, that text becomes the query.
|
||||
- **Clear screen** (⌘K, or Ctrl+Shift+K on Windows and Linux; when the terminal doesn’t have focus, ⌘K still focuses the sidebar’s search box): clears the screen and the scrollback, leaving only the prompt line the cursor is on, including anything typed but not yet entered. Clearing happens only locally; nothing is sent to the server. It isn’t available while a full-screen program such as vim or top is running.
|
||||
- **Links**: `http://` and `https://` addresses in the output, and links that programs mark explicitly (such as `ls --hyperlink` and gcc’s error messages), are shown in blue and underlined. Hold ⌘ (Ctrl on Windows and Linux) and click to open one in the system browser; a plain click still selects text. Rest the pointer on a link for a moment to see its real address: for a link a program marks, the text shown can differ from the address. A trailing period, comma or unmatched closing parenthesis isn’t part of the address, and a long address wrapped over two lines is still one link. Other schemes (such as `file://`) aren’t treated as links.
|
||||
- **Mouse**: when programs such as vim (`:set mouse=a`), htop, tmux (`set -g mouse on`) and mc ask for the mouse, clicks, drags and the scroll wheel all go to the program. Dragging with Shift held still selects text, and scrolling with Shift held still scrolls through the scrollback; right-clicking always opens ShellRS’s menu.
|
||||
- **Mouse**: when programs such as vim (`:set mouse=a`), htop, tmux (`set -g mouse on`) and mc ask for the mouse, clicks, drags and the scroll wheel all go to the program. Dragging with Shift held still selects text, and scrolling with Shift held still scrolls through the scrollback; right-clicks never go to the program and do what “Right-click” in the settings says.
|
||||
- **Notifications**: programs can ask for a notification (OSC 9, or OSC 777’s `notify;title;body`), and a terminal bell alerts you too. When ShellRS isn’t in front, you get a system notification (macOS asks for permission the first time), and clicking it takes you back to that terminal; when ShellRS is in front but the terminal is in another tab, a notice appears at the top right of the window, and clicking it switches there. Notifications that programs ask for appear even when the terminal is right in front of you; the bell alerts you only when you can’t see that terminal (Tab completion and the like ring it too). The notification title is the tab name, so you can tell which host it came from. Each terminal alerts at most once every 2 seconds for program notifications and once every 10 seconds for the bell. Settings › Terminal › Notifications can turn each one off. To be told when a command finishes, append `; printf '\e]9;Done\a'` or `; tput bel` to it; command-line tools such as Claude Code alert you the same way when they need you.
|
||||
- **Highlighting**: the rules in Settings › Highlighting show matching text in the rule’s color in every terminal, scrollback included. Only the display changes: the server’s output and copied text stay the same; matching keeps working after the window is resized and long lines wrap, and a word wrapped over two lines is colored in both parts. The feature is off by default and works only once you turn on “Highlight keywords” at the top of the page; turning it off disables all the rules at once. “Preview” shows sample output in the current terminal’s font and colors, and rule changes show up at once in the preview and in open terminals. The rules table has one rule per row, edited right in the table: “On”, “Regular expression” (case-sensitive; start it with `(?i)` to ignore case), “Note”, a color (click the swatch for a color picker, or type `#rrggbb` next to it) and “Notify”; the trash can at the end of the row deletes the rule. A mistake in a regular expression is pointed out below the field, and the rule doesn’t apply until it’s fixed. Rules apply from top to bottom, and where they overlap, the earlier rule wins; drag the handle at the start of a row to reorder. Three sample rules come to start with: `ERROR` in red, `WARN` in amber and IPv4 addresses in blue, all on and not notifying, so turning on the main switch shows the effect right away; you can change or delete them. A rule with “Notify” checked alerts you when you can’t see the terminal, the same way as the bell: a system notification when ShellRS isn’t in front, and a notice at the top right of the window when the terminal is in another tab. The title is “tab name: note” (the regular expression when the note is empty), and the body is the matching line. Only new output is checked, each line once it ends; each terminal alerts at most once every 10 seconds, and the same line redrawn over and over at most once every 5 minutes. Nothing is highlighted or alerted in full-screen programs such as vim, less, top and tmux. **Not implemented yet: per-host rules, text styles such as bold, and reordering with the keyboard.**
|
||||
- **Remote copy**: what remote programs copy with OSC 52 (such as tmux with `set -g set-clipboard on`, or vim’s OSC 52 plugins) goes straight to this computer’s clipboard. Remote programs can’t read this computer’s clipboard.
|
||||
@@ -250,7 +252,7 @@ Open a host’s “SFTP” tab to browse local and remote folders. As with termi
|
||||
Each pane has two rows of toolbar, with the path label below them. When a pane is too narrow, buttons hide whole, from right to left, rather than wrapping (their commands are still in the context menus and shortcuts). A new SFTP tab splits evenly between the local and remote sides; wherever you drag the dividers between the sides and the queue, they stay there until the tab closes, even when you switch to another tab and back:
|
||||
|
||||
- First row (navigation):
|
||||
- The folder list: lists every level from the root to the current folder, plus Home, Desktop, Documents and Downloads on the local side; choose one to go there.
|
||||
- The folder list: lists every level from the root to the current folder, plus Home, Desktop, Documents and Downloads on the local side, and on Windows every drive (such as “C: Local disk” and “D: Local disk”); choose one to go there.
|
||||
- Bookmarks: the button opens the “Open folder” dialog (see below); the drop-down arrow next to it lists this host’s bookmarks for this side, which you click to go there, and can also add or remove the current path. Bookmarks are saved in the local database and deleted along with the host.
|
||||
- Parent folder, Root folder, Home folder, Refresh, Back, Forward.
|
||||
- Show / hide hidden files: files and folders whose names start with `.`. An open eye means they’re shown, a crossed-out eye that they’re hidden. Local and remote each have their own switch, independent of each other; each side’s choice applies to every SFTP tab and is saved in the settings; both are hidden by default. While they’re hidden, the bottom of the pane says “N hidden”; while they’re shown, hidden files’ text is lighter.
|
||||
@@ -280,7 +282,7 @@ Works like WinSCP’s Open directory dialog. Open it from the path label, the bo
|
||||
|
||||
The status bar below the list normally shows the number of items and how many are selected; while connecting, or when reading a folder takes longer than 0.3 seconds, it shows “Connecting to SFTP…” or “Reading folder…”. When a folder can’t be read or the connection drops, the reason appears in red at the far bottom left of the window (where “Connected to” and the host’s name normally are). A dropped connection is noticed even while idle, without waiting for a refresh, as soon as the same host’s terminal would notice. To reconnect, use the “Reconnect” button in the tab bar or the tab menu. While disconnected, any action in the remote list (refreshing, opening a folder, deleting, uploading, downloading and so on) brings up a “Lost the SFTP connection to …” dialog that explains why and can “Reconnect” right away. None of this makes the list move.
|
||||
|
||||
The columns are the same as in WinSCP: Name, Size, Type and Modified on the local side; Name, Size, Modified, Permissions and Owner on the remote side.
|
||||
The columns are the same as in WinSCP: Name, Size, Type and Modified on the local side; Name, Size, Modified, Permissions and Owner on the remote side. Click a column header (or the arrow at its right) to sort by that column: descending, then ascending, then back to by name; folders always come before files. The order and any column widths you dragged stay when you open another folder or refresh.
|
||||
|
||||
- Sizes are shown in KB by default (`4,008,960 KB`, rounded up; only empty files are 0 KB), as in WinSCP. Right-click the “Size” column header to switch to “Bytes” (`4,105,175,040 B`), “Kilobytes (KB)” or “Short (B, KB, MB, GB)”, which uses B, KB, MB or GB depending on the size, such as `3.8 GB`; the choice applies to every SFTP tab and is saved in the settings. Sorting by size always uses the exact number of bytes.
|
||||
- Times look like `2026/4/22 12:44:53`.
|
||||
@@ -384,7 +386,7 @@ Works like WinSCP’s queue panel: it appears below the two panes when there are
|
||||
|
||||
## Resuming and replacing
|
||||
|
||||
Files are first written to `<destination file name>.filepart`. Write requests go through a bounded pipeline, and the real file is published only after every write has been acknowledged and the file has closed successfully; when the server supports it, `fsync@openssh.com` is called first. Servers that support `posix-rename@openssh.com` replace the file atomically; on other servers, the old file is first moved to `.shellrs-<UUID>.backup` and then the new file is published, a compatibility path that isn’t guaranteed to be atomic. If the reply to a publish is lost, ShellRS recovers using the managed temporary path, the stage, the type and the size; when it can’t tell safely, it keeps the recovery files and reports it.
|
||||
Files are first written to `<destination file name>.filepart`. Write requests go through a bounded pipeline, and once every write has been acknowledged and the file has closed successfully, its modification time (and, over an existing file, that file’s permissions) is set by path before the real file is published. Over an existing file, the old file is first moved to `.shellrs-<UUID>.backup`, then the new file is published and the backup deleted; this replacement isn’t atomic, as the destination is briefly missing between the two renames. Like WinSCP, transfers use no SFTP extensions (`fsync@openssh.com`, `posix-rename@openssh.com` and so on) and set no attributes on an open file: some bastion hosts refuse these requests, or even end the whole session over them. If the reply to a publish is lost, ShellRS recovers using the managed temporary path, the stage, the type and the size; when it can’t tell safely, it keeps the recovery files and reports it.
|
||||
|
||||
Resume records are kept in `upload-resume/` in the app’s data folder, as versioned JSON written atomically; they never contain passwords, passphrases or private keys, and they don’t change the database schema. A record ties together the endpoint, host fingerprint, source, destination and safe-replace stage. Resuming works as in WinSCP: ShellRS reads the current size of the remote `.filepart`, skips that much of the local file and carries on; it’s up to you to make sure the source is still the same version. Uploads never read the remote file back to verify it.
|
||||
|
||||
|
||||
+19
-11
@@ -16,7 +16,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
|
||||
|
||||
主机对话框的「认证方式」有三种:
|
||||
|
||||
- **密码**:填用户名和密码,密码保存在系统钥匙串;密码留空则每次连接时询问。只用密码登录,不会先拿 SSH Agent 或私钥去试。
|
||||
- **密码**:填用户名和密码,密码保存在系统钥匙串;密码留空则每次连接时询问。只用密码登录,不会先拿 SSH Agent 或私钥去试。服务器只在键盘交互式认证里收密码时(如 ESXi),保存的密码会自动回答它唯一的隐藏问题;还有验证码等别的问题时照样询问。
|
||||
- **使用凭据**:选一条保存好的凭据,用户名和登录方式都来自凭据(见下面「凭据」),对话框里不再填用户名和密码。「测试连接」同样使用凭据保存的密码。要用私钥登录,就建一条密钥凭据。
|
||||
- **无密码**:只填用户名。依次尝试服务器免认证、SSH Agent 和 `~/.ssh` 下的默认私钥(`id_ed25519`、`id_ecdsa`、`id_rsa`),和命令行 `ssh` 默认的做法一样。服务器要求密码时连接失败并说明原因,不会询问;服务器在密钥之后再要验证码时照样询问。
|
||||
|
||||
@@ -108,9 +108,10 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
|
||||
- **外观 → 常规 → 应用外观**:深色、浅色、跟随系统,默认跟随系统;选「跟随系统」时随系统的深浅色切换。标题栏的主题按钮也会改写这一项。
|
||||
- **外观 → 主题**:左栏是浅色主题,右栏是深色主题,各选一套:应用外观为浅色时用左栏选中的那套,深色时用右栏的;跟随系统时随系统一起切换,标题栏的主题按钮切换时也一样。默认只有终端按所选主题配色,界面保持中性的灰色调(深色是不刺眼的炭灰,不是纯黑);打开上方的「界面跟随主题」,界面也按主题配色:侧栏、标签栏、对话框等用主题的底色和文字色,成功、警告、错误等状态用主题的绿、黄、红。每张卡片用该主题的颜色显示一段示例,选中的右上角有蓝色对勾。默认是 ShellRS Light 和 ShellRS Dark,与界面同色。内置浅色、深色各 10 套:浅色 ShellRS Light、Solarized Light、Tokyo Day、Catppuccin Latte、Flexoki Light、One Half Light、Gruvbox Light、Kanagawa Lotus、Rosé Pine Dawn、Everforest Light;深色 ShellRS Dark、Solarized Dark、Tokyo Night、Catppuccin Mocha、Flexoki Dark、One Half Dark、Gruvbox Dark、Kanagawa Wave、Dracula、Nord。同一主题的浅色版和深色版在两栏的同一行。改动立即作用于所有已打开的终端,字体和关键字高亮的预览也用当前主题;vim 等程序询问终端的背景色时,得到的是当前主题的颜色。**尚未实现:自定义主题、导入配色、按主机指定主题。**
|
||||
- **终端 → 字体配置**:字体(只列出本机安装的等宽字体,默认是系统的等宽字体,macOS 上为 Menlo)、字号(10–28 像素,默认 13)、行高(字号的 1.0–2.0 倍,默认 1.54,即原来的 20 像素行高)。改动立即作用于所有已打开的终端;在终端里按 ⌘= / ⌘-(其他平台 Ctrl)也能逐级调字号,⌘0 回到默认。下方的预览按终端的排法显示字母、数字、易混字符、符号、中文、制表符和一行命令。设置文件里的字体在本机没有安装时,使用默认字体。
|
||||
- **终端 → 终端交互**:「选中复制」打开后,用鼠标选中文字、松开时就复制到系统剪贴板,不用再按 ⌘C;默认关闭,单击没选中文字时剪贴板不变。「右键行为」决定在终端里按右键做什么:显示菜单(默认)、粘贴(直接粘贴剪贴板,同 ⌘V)、无动作。选后两种时,菜单里的复制、粘贴、查找、清屏仍可用快捷键,打开 SFTP、重新连接在标签的右键菜单里。改动立即作用于所有已打开的终端。
|
||||
- **关键字高亮**:总开关、预览和规则表,终端里按规则给文字上色、匹配时提醒,见「终端」一节。
|
||||
- **键盘快捷键**:主要功能的快捷键,分通用、标签、终端、编辑器四组,每行是功能名和它的按键。点按键进入「按下组合键」,按下新的组合就换上;Esc 或点别处放弃。行尾两个按钮:禁用(之后显示「无」,再点一下恢复默认)、恢复默认;页面上方的「重置」也能逐项恢复。改动立即生效,工具栏提示和右键菜单里显示的按键跟着变。
|
||||
- 组合键要带 ⌘、⌥ 或 ⌃(其他平台 Ctrl 或 Alt),或者是 F1–F12;「切换到标签 1…9」按任意一个带修饰键的数字即可,九个一起换。
|
||||
- 组合键要带 ⌘、⌥ 或 ⌃(其他平台 Ctrl 或 Alt),或者是 F1–F12,或者 Shift 加 Insert、Delete、Home、End、Page Up、Page Down(方向键上方那六个,不挡打字);「切换到标签 1…9」按任意一个带修饰键的数字即可,九个一起换。
|
||||
- 已被同一处的另一个快捷键、或输入框等内置功能占用的组合会被拒绝,并提示被谁占用;想换就先改或禁用那一个。终端、编辑器里的快捷键可以和通用快捷键同键,在终端、编辑器里以前者为准(如 ⌘K 在终端里是清屏)。
|
||||
- 默认(macOS / 其他平台):新建主机 ⌘N / Ctrl+N,新建分组 ⌘⇧N / Ctrl+Shift+N,打开设置 ⌘, / Ctrl+,,显示或隐藏侧栏 ⌘B / Ctrl+B,显示或隐藏右侧栏 ⌘⌥B / Ctrl+Alt+B,聚焦搜索 ⌘K / Ctrl+K,放大、缩小界面和实际大小 ⌘= ⌘- ⌘0 / Ctrl+= Ctrl+- Ctrl+0,退出 ⌘Q / Ctrl+Q;新建本地终端 ⌘T / Ctrl+T,关闭标签 ⌘W / Ctrl+W,下一个标签 ⌘⇧] / Ctrl+Tab,上一个标签 ⌘⇧[ / Ctrl+Shift+Tab,切换到标签 1…9 ⌘1…⌘9 / Alt+1…9(9 是最后一个);终端的复制、粘贴、查找、查找下一个、查找上一个、清屏 ⌘C ⌘V ⌘F ⌘G ⌘⇧G ⌘K / Ctrl+Shift+C、Ctrl+Shift+V、Ctrl+Shift+F、F3、Shift+F3、Ctrl+Shift+K,放大、缩小字号和默认字号 ⌘= ⌘- ⌘0 / Ctrl+= Ctrl+- Ctrl+0;编辑器保存 ⌘S / Ctrl+S。标签切换在当前标签所在的那组标签里进行,首尾循环。⌘= / ⌘- / ⌘0 在终端里调终端字号(每次 1 像素,范围同「终端 › 字号」的 10–28,写进设置,下次启动保留),在终端外缩放整个界面(五档,重启后回到默认)。
|
||||
- SFTP 文件列表、图片预览的快捷键和 Enter、方向键、Tab、Esc 这些固定键不在这里改。
|
||||
@@ -124,7 +125,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
|
||||
|
||||
在 设置 → 外部 CLI 中:
|
||||
|
||||
- **启用外部 CLI**:默认关闭。关闭时 ShellRS 仍在监听,但拒绝所有请求,命令会提示去这里打开。开启后,本机当前用户下的任何程序都能这样使用已保存的主机,也能修改主机和凭据(没有另外的开关)。套接字只对当前用户可读写,连接时还会核对对方的用户身份;Windows 的管道同样只允许当前用户、只接受本机连接,命令在发请求之前还会确认管道属于当前用户(或管理员),防止别人抢注同名管道。
|
||||
- **启用外部 CLI**:默认关闭。关闭时 ShellRS 仍在监听,但拒绝所有请求,命令会提示去这里打开。开启后,本机当前用户下的任何程序都能这样使用已保存的主机,也能修改主机和凭据,还能在已打开的远程终端里输入命令(`exec --terminal`,包括已切到 root 的终端),都没有另外的开关。套接字只对当前用户可读写,连接时还会核对对方的用户身份;Windows 的管道同样只允许当前用户、只接受本机连接,命令在发请求之前还会确认管道属于当前用户(或管理员),防止别人抢注同名管道。
|
||||
- **CLI 二进制**:把 `shellrs` 放进 PATH。
|
||||
- macOS:链接到 `/usr/local/bin/shellrs`,目录不可写时弹系统授权框。
|
||||
- Linux:链接到 `~/.local/bin/shellrs`。
|
||||
@@ -139,6 +140,7 @@ shellrs hosts list [-q <关键词>] [--json] # 列出主机;输出不是
|
||||
shellrs hosts show <ID> [--json] # 一台主机的全部配置
|
||||
shellrs exec <ID> "<命令>" # 或 --stdin 从标准输入读命令
|
||||
shellrs exec --json # 从标准输入读 {"host", "command"},输出 JSON
|
||||
shellrs exec <ID> --terminal "<命令>" # 在 ShellRS 已打开的终端里执行,见下文
|
||||
shellrs upload <ID> <本地路径> <远程路径>
|
||||
shellrs download <ID> <远程路径> <本地路径>
|
||||
shellrs sync <ID> <本地目录> <远程目录> [--delete]
|
||||
@@ -148,6 +150,11 @@ shellrs credentials list | show <ID> | create | update <ID> | delete <ID>
|
||||
|
||||
- `<ID>` 是 16 位的主机 ID,即 `shellrs hosts list` 的 `id`,也就是主机右键「复制 ID」复制的内容。列表里还有标签开着的临时连接和外部连接(`"temporary": true`),见「临时连接」和「从堡垒机打开(外部连接)」。
|
||||
- `exec` 每次临时建立连接,执行完即断开。远程命令没有标准输入,退出码就是远程命令的退出码;被信号终止时是 128 加信号编号。
|
||||
- `exec --terminal`(`exec --json` 里写 `"terminal": true`)不重新登录,而是把命令打进这台主机在 ShellRS 里已打开的远程终端,给只允许登录一次的堡垒机用(见「从堡垒机打开(外部连接)」)。它先发 Ctrl-C,打断终端里正在运行的程序、清掉输入行里没提交的内容,再输入一行探测和一行命令;这两行和命令的输出都显示在终端里。命令在终端当前的用户、目录和机器上执行(`su`、`sudo -i`、嵌套 `ssh` 之后也是),`--json` 的结果多一项 `context`(`user`、`host`、`cwd`、`shell`、`interp`)写明在哪里。命令的输出经管道,程序看不到终端:没有颜色和分页器,stderr 并进 stdout;命令没有标准输入,要密码的程序会停在终端里等。用哪个终端:右侧栏正对着的终端是这台主机的就用它,否则用这台主机最新的、已连接的终端。
|
||||
- 只支持 Linux 上的 bash、dash 和 busybox ash(否则报 `unsupported_shell`)。编码后的命令行,bash 和 dash 最多 4000 字节,busybox 最多 1000 字节(否则报 `too_long`)。
|
||||
- 以下情况报 `terminal_busy`,命令没有执行:全屏程序(vim、less、top、tmux 等)占着终端;上一条 `--terminal` 命令还没结束;5 秒内 shell 没有回应(前台程序不理 Ctrl-C,如 python、mysql);15 秒内 shell 没有开始执行(可能被堡垒机的命令过滤或复核拦下,这时会再发一次 Ctrl-C)。终端没打开或没连接报 `no_terminal`。
|
||||
- 执行过程中没有时限;命令行工具中途被结束时(如 Agent 超时),终端里的命令收到 Ctrl-C。
|
||||
- 那两行行首有空格,bash 设了 `ignorespace` 时不记进历史,否则会记下。停在不带提示的 `read -s`、或 vi 编辑模式的命令模式时认不出,会报「没有回应」。
|
||||
- 上传和下载沿用 SFTP 标签的传输引擎(递归、`.filepart`、断线重连),目标路径按 scp 的规则:目标是已存在的目录就放进去并保留原名,否则目标就是副本自己的路径,其父目录必须存在。已存在的文件直接覆盖;新建的文件保留源文件的可执行权限。`~` 表示登录目录。
|
||||
- `sync` 只从本机到主机:把本地目录里的内容同步进远程目录。远程目录不存在就新建(父目录必须存在),是文件就报错,不能是根目录 `/`。大小和修改时间都没变的文件、指向不变的链接跳过,摘要里计为「未变」,所以再同步一次只传改过的;其余文件直接覆盖,新文件保留可执行权限。`--delete` 先删掉远程目录里本地没有的、或类型不同的项(不跟随链接),但不删传输进行中留下的 `.filepart` 和 `.shellrs-….backup`,本地读不了的目录也不清理;删不掉的项计为失败。
|
||||
- `hosts` 和 `credentials` 管理保存的主机和凭据。`create`、`update` 从标准输入读一个 JSON 对象(`update` 只改给出的字段),字段见 `shellrs hosts create --help` 和 `shellrs credentials create --help`;`show --json` 的输出可以改了直接交回 `update`。校验和对话框一样,报错用对话框的说法:
|
||||
@@ -157,7 +164,7 @@ shellrs credentials list | show <ID> | create | update <ID> | delete <ID>
|
||||
- 临时连接和外部连接能 `show`,不能修改或删除。
|
||||
- `hosts delete` 不确认,连同主机的端口转发规则一起删;主机在 ShellRS 里有打开的标签时拒绝(`host_in_use`),加 `--force` 才关掉标签再删。经它跳转的主机,那一跳变成「已删除的主机」。`credentials delete` 也不确认,用它的主机改为自己登录(同界面)。
|
||||
- 改动由 ShellRS 一条一条执行:10 秒内没轮到就撤回、不做;开始执行后会等到完成。
|
||||
- 失败时在标准错误输出 `shellrs: [错误码] 说明`,退出码 255。错误码有 `not_running`(ShellRS 未运行)、`not_enabled`(未启用外部 CLI)、`host_not_found`、`credential_not_found`、`host_key_unknown`(还没在 ShellRS 里连过这台主机)、`host_key_changed`、`missing_credential`(没有保存密码或口令)、`connect_failed`、`transfer_failed`、`bad_request`、`host_in_use`、`save_failed`(改动已做,但没能全部写进数据库或钥匙串)、`version_mismatch`(命令和正在运行的 ShellRS 版本不同,或 ShellRS 太旧、不认识这个命令)。CLI 不弹任何询问:陌生主机、缺少密码都直接失败,请先在 ShellRS 里连接一次。传输完成但有项目失败时退出码为 1。
|
||||
- 失败时在标准错误输出 `shellrs: [错误码] 说明`,退出码 255。错误码有 `not_running`(ShellRS 未运行)、`not_enabled`(未启用外部 CLI)、`host_not_found`、`credential_not_found`、`host_key_unknown`(还没在 ShellRS 里连过这台主机)、`host_key_changed`、`missing_credential`(没有保存密码或口令)、`connect_failed`、`transfer_failed`、`bad_request`、`host_in_use`、`save_failed`(改动已做,但没能全部写进数据库或钥匙串)、`version_mismatch`(命令和正在运行的 ShellRS 版本不同,或 ShellRS 太旧、不认识这个命令),以及 `exec --terminal` 才有的 `no_terminal`、`terminal_busy`、`unsupported_shell`、`too_long`。CLI 不弹任何询问:陌生主机、缺少密码都直接失败,请先在 ShellRS 里连接一次。传输完成但有项目失败时退出码为 1。
|
||||
- `exec --json` 从标准输入读 `{"host": "<ID>", "command": "<命令>"}`,命令结束后输出 `{"exit_code", "stdout", "stderr"}`,出错时输出 `{"error": {"code", "message"}}`,退出码和不加 `--json` 时一样。输出只含 ASCII,中文等字符都转成 `\uXXXX`;不是 UTF-8 的输出换成 U+FFFD。命令不经过本机 shell 的引号处理,控制台的代码页也不会把输出弄乱,适合 Windows PowerShell 和需要分开读标准输出、标准错误的 Agent。
|
||||
- 支持 macOS、Linux 和 Windows。Windows 的 PowerShell 里执行命令优先用 `exec --json`(`@{ host = '<ID>'; command = '…' } | ConvertTo-Json -Compress | shellrs exec --json`);要边执行边看输出时(长时间构建、大量日志),改用 here-string 经 `--stdin` 传入(`@'…'@ | shellrs exec <ID> --stdin`),并和 `list`、`upload`、`download` 一样,先执行 `$OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()`,否则中文会变成问号或乱码。命令文本会去掉开头的 BOM、把 CRLF 换成 LF,远程 shell 不会看到多余的回车符。
|
||||
|
||||
@@ -182,7 +189,7 @@ ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口]
|
||||
- Windows:安装目录里的 `shellrs.exe`(默认 `%LOCALAPPDATA%\Programs\ShellRS\shellrs.exe`)。不要用 PATH 里的 `shellrs.exe`:那是外部 CLI 的副本。
|
||||
- macOS:`/Applications/ShellRS.app/Contents/MacOS/shellrs`。不要用 `open -a ShellRS --args …`:ShellRS 已在运行时,系统会把参数丢掉。
|
||||
- Linux:AppImage 本身。
|
||||
- 外部 CLI 的 `shellrs hosts list` 也列出外部连接(JSON 里 `"temporary": true`,表格的分组一栏写「(未保存)」),标签开着时 Agent 可以用它的 ID 执行命令、传输文件,这期间 ID 不变,可以一直用。标签关掉后 ID 失效;堡垒机再拉起一次(即使是同一台资产)是新的外部连接,ID 也是新的。和保存的主机一样,每条命令都用链接里的用户名和密码重新登录一次;堡垒机给的若是只能用一次的令牌,这次登录会被拒绝。
|
||||
- 外部 CLI 的 `shellrs hosts list` 也列出外部连接(JSON 里 `"temporary": true`,表格的分组一栏写「(未保存)」),标签开着时 Agent 可以用它的 ID 执行命令、传输文件,这期间 ID 不变,可以一直用。标签关掉后 ID 失效;堡垒机再拉起一次(即使是同一台资产)是新的外部连接,ID 也是新的。和保存的主机一样,每条命令都用链接里的用户名和密码重新登录一次;堡垒机给的若是只能用一次的令牌,这次登录会被拒绝,这时改用 `shellrs exec --terminal`,在外部连接已打开的终端里执行命令(见「外部 CLI」)。
|
||||
- 命令行参数里的密码在本机的进程列表里看得到,这是堡垒机这样传参本身的问题。
|
||||
|
||||
## 标签页
|
||||
@@ -191,12 +198,13 @@ ShellRS /sessionname=标签名 sftp://用户[:密码]@地址[:端口]
|
||||
|
||||
## 终端
|
||||
|
||||
右键终端内容区可复制、粘贴、查找、清屏;远程终端另有打开 SFTP、重新连接和断开连接。断开连接只断开这一个标签,标签保留,可随时重新连接,同一台主机的其他标签和 SFTP 不受影响。
|
||||
右键终端内容区可复制、粘贴、查找、清屏;远程终端另有打开 SFTP、重新连接和断开连接。「设置 › 终端 › 终端交互」可以把右键改成直接粘贴或不做任何事,也可以打开「选中复制」。断开连接只断开这一个标签,标签保留,可随时重新连接,同一台主机的其他标签和 SFTP 不受影响。
|
||||
|
||||
- **回滚历史**:每个终端保留最近 10000 行。用滚轮或触控板往上翻看,这时右侧出现滚动条(和应用里其他列表一样,按系统的「显示滚动条」设置),可以拖动,点轨道直接跳到那个位置。vim、less、top 这类全屏程序没有回滚历史,不显示滚动条。
|
||||
- **查找**(⌘F,其他平台 Ctrl+Shift+F):在终端右上角打开查找条,按字面量搜索屏幕和回滚历史。查询全为小写时不区分大小写,含大写字母时区分。所有匹配高亮显示,当前项颜色更深,查找条显示「当前项/总数」。回车或 ⌘G(F3)跳到下一项(更新的输出),Shift+回车或 ⌘⇧G(Shift+F3)跳到上一项(更早的输出),到头后循环。Esc 关闭。打开时如有选中文字,会作为查询词。
|
||||
- **清屏**(⌘K,其他平台 Ctrl+Shift+K;焦点不在终端时 ⌘K 仍是聚焦侧栏的搜索框):清掉屏幕和回滚历史,只留下光标所在的提示符行,包括已输入但未回车的内容。清屏只在本地进行,不向服务器发送任何内容。vim、top 等全屏程序运行时不可用。
|
||||
- **链接**:输出里的 `http://`、`https://` 地址,以及程序明确标出的链接(如 `ls --hyperlink`、gcc 的报错),显示为带下划线的蓝色。按住 ⌘(其他平台 Ctrl)单击,用系统浏览器打开;不按时单击照旧是选择文字。鼠标在链接上停一会儿会显示它真正的地址:程序标出的链接,显示的文字可以和地址不同。地址末尾的句号、逗号和不成对的右括号不算在内;被折成两行的长地址仍是一个链接。其他协议(如 `file://`)不当作链接。
|
||||
- **鼠标**:vim(`:set mouse=a`)、htop、tmux(`set -g mouse on`)、mc 这类程序要求鼠标时,单击、拖动和滚轮都交给程序。按住 Shift 拖动仍是选择文字,按住 Shift 滚动仍是翻看回滚历史;右键始终是 ShellRS 的菜单。
|
||||
- **鼠标**:vim(`:set mouse=a`)、htop、tmux(`set -g mouse on`)、mc 这类程序要求鼠标时,单击、拖动和滚轮都交给程序。按住 Shift 拖动仍是选择文字,按住 Shift 滚动仍是翻看回滚历史;右键从不交给程序,按「右键行为」的设置处理。
|
||||
- **通知**:程序可以请求通知(OSC 9,或 OSC 777 的 `notify;标题;正文`),终端响铃时也会提醒。ShellRS 不在前台时发系统通知(macOS 第一次会询问是否允许),点通知回到那个终端;ShellRS 在前台但终端在别的标签时,在窗口右上角提示,点一下切过去。程序请求的通知即使终端就在眼前也会提示;响铃只在看不见那个终端时提醒(Tab 补全等也会响铃)。通知标题是标签名,看得出是哪台主机发的。同一终端的程序通知 2 秒内、响铃 10 秒内只提醒一次。设置 › 终端 › 通知可以分别关掉。命令跑完想被提醒,可以在后面加上 `; printf '\e]9;完成\a'` 或 `; tput bel`;Claude Code 等命令行工具需要你处理时也会这样提醒。
|
||||
- **关键字高亮**:设置 › 关键字高亮 里的规则把匹配的文字按规则的颜色显示在所有终端里,回滚历史里的也一样。只改显示:服务器的输出不变,复制出的文字也不变;窗口缩放、长行折行后照样匹配,被折成两行的词两段都上色。这个功能默认关闭,打开页面上方的「启用关键字高亮」后才生效,关掉它就一次停用全部规则;「预览」用当前终端的字体和配色显示一段示例输出,规则改动立即反映在预览和已打开的终端里。规则表一行一条,直接在表里改:「启用」、「正则表达式」(区分大小写,开头加 `(?i)` 不区分)、「备注」、颜色(点色块用取色器,或在旁边填 `#rrggbb`)、「通知」,行尾的垃圾桶删除这一条。正则写错时在框下方提示,这条规则暂不生效。规则从上到下应用,重叠处前面的规则优先;按住行首的把手拖动可调整顺序。初始有三条示例:`ERROR` 红色、`WARN` 琥珀色、IPv4 地址蓝色,都已启用、不通知,打开总开关就能看到效果,可以修改或删除。勾选「通知」的规则在看不到那个终端时提醒,和响铃一样:ShellRS 不在前台时发系统通知,终端在别的标签时在窗口右上角提示;标题是「标签名:备注」(没填备注时用正则),正文是匹配的那一行。只检查换了行的新输出,同一终端 10 秒内只提醒一次,反复重画的同一行 5 分钟内只提醒一次。vim、less、top、tmux 这类全屏程序里不高亮,也不提醒。**尚未实现:按主机区分规则、加粗等文字样式、用键盘调整顺序。**
|
||||
- **远程复制**:远程程序用 OSC 52 复制的内容(如 tmux 的 `set -g set-clipboard on`、vim 的 OSC 52 插件)直接进本机剪贴板。远程程序读不了本机剪贴板。
|
||||
@@ -250,7 +258,7 @@ bash 在 shell 退出时才把这次执行的命令写进历史文件,所以
|
||||
每一侧面板都有两行工具栏,下方是路径标签。面板太窄放不下时,按钮从右往左整个隐藏,不会换行(对应的命令在右键菜单和快捷键里仍然可用)。新开的 SFTP 标签本地、远程两侧各占一半;两侧和队列之间的分隔线拖到哪里,在这个标签关闭之前都会记住,切到别的标签再回来也不变:
|
||||
|
||||
- 第一行(导航):
|
||||
- 「目录列表」:列出从根到当前目录的每一级,本地另有主目录、桌面、文稿、下载,选中即前往。
|
||||
- 「目录列表」:列出从根到当前目录的每一级,本地另有主目录、桌面、文稿、下载,Windows 上还有各个驱动器(如「C: 本地磁盘」「D: 本地磁盘」),选中即前往。
|
||||
- 书签:点按钮打开「打开目录/书签」(见下);旁边的下拉箭头列出这台主机这一侧的书签,点击即前往,也可添加或移除当前路径。书签保存在本地数据库,删除主机时一并删除。
|
||||
- 上级目录、根目录、主目录、刷新、后退、前进。
|
||||
- 显示 / 不显示隐藏文件:名称以 `.` 开头的文件和目录。睁开的眼睛表示正在显示,划掉的眼睛表示不显示。本地和远程各自一个开关,互不影响;每一侧的选择对所有 SFTP 标签生效,保存在设置里,默认都不显示。不显示时面板底部写明「隐藏 N 项」;显示时隐藏文件的文字颜色浅一些。
|
||||
@@ -280,7 +288,7 @@ bash 在 shell 退出时才把这次执行的命令写进历史文件,所以
|
||||
|
||||
列表下方的状态栏平时显示项目数和已选数量;连接中和读取目录超过 0.3 秒时显示「正在连接 SFTP…」或「正在读取目录…」。读不了目录或连接断开时,原因用红字显示在窗口最左下角(平时显示「已连接 主机的名称」的位置);空闲时断网也会自己发现,不用等到刷新,时机和同一台主机的终端一样;重新连接用标签栏的「重新连接」按钮或标签菜单;断开时在远程列表里做任何操作(刷新、打开目录、删除、上传下载等)会弹出「SFTP 连接已断开」对话框,说明原因,并可直接「重新连接」。这些都不会让列表移动。
|
||||
|
||||
列与 WinSCP 相同:本地为名称、大小、类型、修改时间,远程为名称、大小、修改时间、权限、所有者。
|
||||
列与 WinSCP 相同:本地为名称、大小、类型、修改时间,远程为名称、大小、修改时间、权限、所有者。点列标题(或它右边的箭头)按这一列排序,依次是降序、升序、恢复按名称;目录始终排在文件前面。换目录、刷新后排序和拖动过的列宽都保持不变。
|
||||
|
||||
- 大小默认以 KB 显示(`4,008,960 KB`,向上取整,只有空文件是 0 KB),与 WinSCP 相同。右键「大小」列标题可切换为「字节」(`4,105,175,040 B`)、「千字节(KB)」或「简短格式」(按大小用 B、KB、MB、GB,如 `3.8 GB`),对所有 SFTP 标签生效并保存在设置里。按大小排序始终按实际字节数。
|
||||
- 时间形如 `2026/4/22 12:44:53`。
|
||||
@@ -384,7 +392,7 @@ bash 在 shell 退出时才把这次执行的命令写进历史文件,所以
|
||||
|
||||
## 断点续传和替换
|
||||
|
||||
文件先写入 `<目标文件名>.filepart`。写入请求采用有界流水线,收到每个写入确认并关闭成功后才发布正式文件;服务器支持时先调用 `fsync@openssh.com`。支持 `posix-rename@openssh.com` 的服务器使用原子替换;其他服务器先将旧文件移到 `.shellrs-<UUID>.backup`,再发布新文件,此兼容路径不保证原子性。发布响应丢失时根据受管理临时路径、阶段、类型和大小恢复,无法安全判断则保留恢复文件并报告。
|
||||
文件先写入 `<目标文件名>.filepart`。写入请求采用有界流水线,收到每个写入确认并关闭成功后,按路径设置修改时间(覆盖时还有原文件的权限),再发布正式文件。覆盖已有文件时,先将旧文件移到 `.shellrs-<UUID>.backup`,再发布新文件,最后删除备份;这样替换不是原子的,两次改名之间目标短暂不存在。和 WinSCP 一样,传输不用任何 SFTP 扩展(`fsync@openssh.com`、`posix-rename@openssh.com` 等),也不对打开的文件设置属性:有的堡垒机拒绝这些请求,甚至因此断开整个会话。发布响应丢失时根据受管理临时路径、阶段、类型和大小恢复,无法安全判断则保留恢复文件并报告。
|
||||
|
||||
续传记录保存在应用数据目录的 `upload-resume/`,采用版本化 JSON 和原子写入,不写入密码、口令或私钥,不改变数据库结构。记录用于绑定端点、主机指纹、来源、目标和安全替换阶段。续传方式与 WinSCP 相同:读取远端 `.filepart` 的当前大小,在本地文件中跳过相同长度并继续传输;使用者需确保来源仍是同一版本。上传过程不会为了校验而回读远端文件内容。
|
||||
|
||||
@@ -439,7 +447,7 @@ ShellRS 不发送主机、凭据、会话内容、命令、文件名或路径,
|
||||
|
||||
前三种附带这些设置和数量,取值都是固定的几种:CPU 架构、应用外观、界面语言、浅色和深色主题(内置主题的名字)、界面是否跟随主题、关键字高亮、外部 CLI 和自动升级是否打开、更新渠道、是否改过快捷键,以及保存的主机、凭据、端口转发、命令片段各有多少(只报档位:0、1-5、6-20、21-100、100+)。
|
||||
|
||||
`usage` 只有次数,没有内容,计的是:SSH 终端连上(另按跳板、代理、凭据、无密码、临时连接、外部连接分别计数)、SFTP 连上(另按临时连接、外部连接计数)、本地终端、端口转发开启(本地、远程、动态)、上传和下载批次、编辑器打开本地和远程文件、预览图片和 Markdown、打开右侧栏的七个工具、命令片段和历史命令送进终端、终端查找、通知(程序、响铃、关键字),外部 CLI 的 `exec`、`upload`、`download`、`sync`、`hosts`、`credentials` 命令,以及打开和关闭关键字高亮、外部 CLI。
|
||||
`usage` 只有次数,没有内容,计的是:SSH 终端连上(另按跳板、代理、凭据、无密码、临时连接、外部连接分别计数)、SFTP 连上(另按临时连接、外部连接计数)、本地终端、端口转发开启(本地、远程、动态)、上传和下载批次、编辑器打开本地和远程文件、预览图片和 Markdown、打开右侧栏的七个工具、命令片段和历史命令送进终端、终端查找、通知(程序、响铃、关键字),外部 CLI 的 `exec`、`exec --terminal`、`upload`、`download`、`sync`、`hosts`、`credentials` 命令,以及打开和关闭关键字高亮、外部 CLI。
|
||||
|
||||
- 计数先存在数据目录的 `analytics.json` 里,退出不会丢,之后再补发。离线或在内网里不影响使用:发不出去时等 1 分钟,之后每次加倍、最长 6 小时再试,而且只在使用 ShellRS 时顺带重试,空闲时不发任何请求。
|
||||
- Aptabase 按请求的 IP 地址推断国家和地区,但不保存 IP。它区分设备用的是 IP 和 User-Agent 加上每天更换的随机盐算出的散列,所以看不到同一台设备跨天的行为。
|
||||
|
||||
+3
-1
@@ -5,7 +5,7 @@
|
||||
在 `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;
|
||||
2. 构建并打包:macOS 按 `aarch64`(Apple Silicon)和 `x86_64`(Intel)分别构建、签名、公证、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 缓存,然后调用官网的部署挂钩重新构建 shellrs.com(官网的下载页和更新日志在构建时读这份清单)。清单里每个包有两个下载地址:先 R2,下载失败时客户端接着试 GitHub Release(仓库是公开的,下载不用登录)。
|
||||
|
||||
版本号带 `-` 的(如 `0.3.0-beta.1`)只发到 beta 通道;正式版同时成为 beta 通道的最新版。在 Actions 里手动运行这个 workflow 只构建打包、不发布,用来演练。客户端只接受比自己新的版本,所以发错的版本撤不回来:把清单改回上一版能阻止更多人升级,修复要发新的补丁版。
|
||||
@@ -19,3 +19,5 @@
|
||||
- **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`、`SITE_DEPLOY_HOOK`(官网部署挂钩的地址,拿到它的人能触发官网重新构建,泄露了就删掉重建)。
|
||||
|
||||
清单格式见 `packaging/manifest.example.json`,由 `packaging/make-manifest.sh` 生成:一个文件里是清单原文和它的 minisign 签名,签名的 trusted comment 写明通道和版本。格式只增加字段、不改已有的;已发出去的 ShellRS 一直按 `/update/v1/` 这个地址检查。
|
||||
|
||||
macOS 产物名分别为 `ShellRS-<版本>-macos-aarch64.{app.zip,dmg}` 和 `ShellRS-<版本>-macos-x86_64.{app.zip,dmg}`。更新清单保留原有的两个平台键,分别记录对应架构包的地址、大小和 SHA-256;已有 universal 客户端按当前运行的架构升级。Apple Silicon 上通过 Rosetta 运行 Intel 版时仍会收到 Intel 包,需要手动安装 ARM 版才能切换。官网在独立仓库中,下载页也需提供 Apple Silicon / Intel 两个入口,读取清单中各自的 installer 地址。
|
||||
+4
-4
@@ -95,11 +95,11 @@ app.shortcut_group.editor:
|
||||
en: Editor
|
||||
|
||||
app.shortcut_refusal.needs_modifier_mac:
|
||||
zh-CN: 快捷键要带 ⌘、⌥ 或 ⌃,或者使用 F1–F12。
|
||||
en: A shortcut needs ⌘, ⌥ or ⌃, or one of F1–F12.
|
||||
zh-CN: 快捷键要带 ⌘、⌥ 或 ⌃,或者使用 F1–F12,或者 Shift 加 Insert、Delete、Home、End、Page Up、Page Down。
|
||||
en: A shortcut needs ⌘, ⌥ or ⌃, or one of F1–F12, or Shift with Insert, Delete, Home, End, Page Up or Page Down.
|
||||
app.shortcut_refusal.needs_modifier:
|
||||
zh-CN: 快捷键要带 Ctrl 或 Alt,或者使用 F1–F12。
|
||||
en: A shortcut needs Ctrl or Alt, or one of F1–F12.
|
||||
zh-CN: 快捷键要带 Ctrl 或 Alt,或者使用 F1–F12,或者 Shift 加 Insert、Delete、Home、End、Page Up、Page Down。
|
||||
en: A shortcut needs Ctrl or Alt, or one of F1–F12, or Shift with Insert, Delete, Home, End, Page Up or Page Down.
|
||||
app.shortcut_refusal.needs_digit_mac:
|
||||
zh-CN: 这一项要按带修饰键的数字 1,例如 ⌘1。
|
||||
en: Press the digit 1 with a modifier, such as ⌘1.
|
||||
|
||||
@@ -345,6 +345,46 @@ cli.change.failures:
|
||||
en: "%{before}; %{next}"
|
||||
|
||||
# Running commands and moving files.
|
||||
cli.exec.try_terminal:
|
||||
zh-CN: "%{message}(堡垒机可能只允许登录一次:改用 shellrs exec --terminal,在 ShellRS 已打开的终端里执行)"
|
||||
en: "%{message} (The bastion host may allow one login only: use shellrs exec --terminal to run the command in the terminal open in ShellRS.)"
|
||||
|
||||
# `exec --terminal`: why the command could not be typed into the terminal,
|
||||
# or did not finish there.
|
||||
cli.terminal.timed_out:
|
||||
zh-CN: ShellRS 没有及时处理这条命令,命令没有执行:请稍后再试
|
||||
en: ShellRS didn't get to the command in time, so it didn't run. Try again later.
|
||||
cli.terminal.no_terminal:
|
||||
zh-CN: 这台主机在 ShellRS 里没有打开的终端:请先在 ShellRS 里打开它的终端
|
||||
en: This host has no terminal open in ShellRS. Open its terminal in ShellRS first.
|
||||
cli.terminal.not_connected:
|
||||
zh-CN: 这台主机在 ShellRS 里的终端没有连接:请先在 ShellRS 里重新连接
|
||||
en: This host's terminal in ShellRS isn't connected. Reconnect it in ShellRS first.
|
||||
cli.terminal.unknown_shell:
|
||||
zh-CN: 认不出终端里的 shell:只支持 Linux 上的 bash、dash 和 busybox ash
|
||||
en: Couldn't tell which shell the terminal runs. Only bash, dash and busybox ash on Linux are supported.
|
||||
cli.terminal.unsupported_shell:
|
||||
zh-CN: 终端里的 shell 是 %{shell}:只支持 bash、dash 和 busybox ash
|
||||
en: The terminal runs %{shell}. Only bash, dash and busybox ash are supported.
|
||||
cli.terminal.too_long:
|
||||
zh-CN: 命令太长:这个终端里,编码后的命令行最多 %{limit} 字节。请拆成几条
|
||||
en: The command is too long. In this terminal the encoded command line takes %{limit} bytes at most; split it up.
|
||||
cli.terminal.full_screen:
|
||||
zh-CN: 终端里正在运行全屏程序(如 vim、less、top、tmux):请先在 ShellRS 里退出它
|
||||
en: A full-screen program (such as vim, less, top or tmux) has the terminal. Quit it in ShellRS first.
|
||||
cli.terminal.another_run:
|
||||
zh-CN: 这个终端里上一条 shellrs exec --terminal 命令还在执行
|
||||
en: The previous shellrs exec --terminal command is still running in this terminal.
|
||||
cli.terminal.no_answer:
|
||||
zh-CN: 终端里的 shell 没有回应:前台程序可能不响应 Ctrl-C(如 python、mysql),或者终端里不是 Linux 上的 bash、dash 或 busybox ash
|
||||
en: The shell in the terminal didn't answer. The program in the foreground may ignore Ctrl-C (such as python or mysql), or the terminal doesn't run bash, dash or busybox ash on Linux.
|
||||
cli.terminal.not_started:
|
||||
zh-CN: shell 没有开始执行这条命令,可能被堡垒机的命令过滤或命令复核拦下了;已发送 Ctrl-C
|
||||
en: The shell didn't start the command; the bastion host's command filter or review may have held it back. Ctrl-C was sent.
|
||||
cli.terminal.disconnected:
|
||||
zh-CN: 命令执行过程中终端断开了,命令可能已执行了一部分
|
||||
en: The terminal disconnected while the command ran; it may have partly run.
|
||||
|
||||
cli.exec.signal:
|
||||
zh-CN: 远程命令被信号 %{signal} 终止
|
||||
en: The remote command was killed by signal %{signal}.
|
||||
|
||||
@@ -97,6 +97,31 @@ settings.terminal.preview_keywords:
|
||||
zh-CN: 预览,字体,字号,行高
|
||||
en: preview,font,size,line height
|
||||
|
||||
settings.interaction.title:
|
||||
zh-CN: 终端交互
|
||||
en: Interaction
|
||||
settings.interaction.copy_on_select:
|
||||
zh-CN: 选中复制
|
||||
en: Copy on select
|
||||
settings.interaction.copy_on_select_description:
|
||||
zh-CN: 选中文本自动进入系统剪贴板,无需按快捷键。
|
||||
en: Selected text is copied to the clipboard without pressing a shortcut.
|
||||
settings.interaction.right_click:
|
||||
zh-CN: 右键行为
|
||||
en: Right-click
|
||||
settings.interaction.right_click_description:
|
||||
zh-CN: 设置鼠标右键在终端中的行为。
|
||||
en: What right-clicking in a terminal does.
|
||||
settings.right_click.nothing:
|
||||
zh-CN: 无动作
|
||||
en: Do nothing
|
||||
settings.right_click.paste:
|
||||
zh-CN: 粘贴
|
||||
en: Paste
|
||||
settings.right_click.menu:
|
||||
zh-CN: 显示菜单
|
||||
en: Show menu
|
||||
|
||||
settings.notifications.title:
|
||||
zh-CN: 通知
|
||||
en: Notifications
|
||||
|
||||
+12
-3
@@ -15,6 +15,18 @@ sftp.place.documents:
|
||||
sftp.place.downloads:
|
||||
zh-CN: 下载
|
||||
en: Downloads
|
||||
sftp.place.drive_local:
|
||||
zh-CN: "%{drive} 本地磁盘"
|
||||
en: "%{drive} Local disk"
|
||||
sftp.place.drive_removable:
|
||||
zh-CN: "%{drive} 可移动磁盘"
|
||||
en: "%{drive} Removable disk"
|
||||
sftp.place.drive_network:
|
||||
zh-CN: "%{drive} 网络驱动器"
|
||||
en: "%{drive} Network drive"
|
||||
sftp.place.drive_optical:
|
||||
zh-CN: "%{drive} CD 驱动器"
|
||||
en: "%{drive} CD drive"
|
||||
|
||||
sftp.error.remote_path_invalid:
|
||||
zh-CN: 远程路径不能为空或包含空字符
|
||||
@@ -305,9 +317,6 @@ sftp.client.no_path:
|
||||
sftp.client.no_link_target:
|
||||
zh-CN: 无法读取链接目标
|
||||
en: Couldn’t read the link target
|
||||
sftp.client.bad_rename_reply:
|
||||
zh-CN: 服务器返回了无效的重命名响应
|
||||
en: The server sent an invalid reply to the rename
|
||||
sftp.client.ambiguous_name:
|
||||
zh-CN: 远端名称包含无法可靠表示的字符,已停止操作
|
||||
en: A remote name has characters that can’t be shown reliably, so the operation stopped
|
||||
|
||||
+2
-2
@@ -365,5 +365,5 @@ ssh.exec.saved_passphrase_rejected:
|
||||
zh-CN: 保存的私钥口令不正确:请先在 ShellRS 中连接一次这台主机并保存密码
|
||||
en: The saved private key passphrase is incorrect. Connect to this host in ShellRS once and save the right one
|
||||
ssh.exec.keyboard_interactive:
|
||||
zh-CN: 服务器要求键盘交互式认证:请先在 ShellRS 中连接一次这台主机并保存密码
|
||||
en: The server asks for keyboard-interactive authentication. Connect to this host in ShellRS once and save the password
|
||||
zh-CN: 服务器在键盘交互式认证中提出了密码以外的问题(如验证码),外部 CLI 无法回答
|
||||
en: The server asks keyboard-interactive questions besides the password (such as a verification code), which the external CLI can’t answer
|
||||
@@ -23,8 +23,10 @@ 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"
|
||||
mac_arm="ShellRS-$version-macos-aarch64.app.zip"
|
||||
mac_intel="ShellRS-$version-macos-x86_64.app.zip"
|
||||
dmg_arm="ShellRS-$version-macos-aarch64.dmg"
|
||||
dmg_intel="ShellRS-$version-macos-x86_64.dmg"
|
||||
win="ShellRS-$version-windows-x86_64-setup.exe"
|
||||
linux="ShellRS-$version-linux-x86_64.AppImage"
|
||||
|
||||
@@ -58,10 +60,12 @@ jq -n -j \
|
||||
--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 mac_arm "$(asset "$mac_arm")" \
|
||||
--argjson mac_intel "$(asset "$mac_intel")" \
|
||||
--argjson win "$(asset "$win")" \
|
||||
--argjson linux "$(asset "$linux")" \
|
||||
--arg dmg "$base/$dmg" \
|
||||
--arg dmg_arm "$base/$dmg_arm" \
|
||||
--arg dmg_intel "$base/$dmg_intel" \
|
||||
--arg win_url "$base/$win" \
|
||||
--arg linux_url "$base/$linux" \
|
||||
'{
|
||||
@@ -74,14 +78,14 @@ jq -n -j \
|
||||
notes: $notes,
|
||||
notes_url: $notes_url,
|
||||
assets: {
|
||||
"macos-aarch64": $mac,
|
||||
"macos-x86_64": $mac,
|
||||
"macos-aarch64": $mac_arm,
|
||||
"macos-x86_64": $mac_intel,
|
||||
"windows-x86_64": $win,
|
||||
"linux-x86_64": $linux
|
||||
},
|
||||
installers: {
|
||||
"macos-aarch64": $dmg,
|
||||
"macos-x86_64": $dmg,
|
||||
"macos-aarch64": $dmg_arm,
|
||||
"macos-x86_64": $dmg_intel,
|
||||
"windows-x86_64": $win_url,
|
||||
"linux-x86_64": $linux_url
|
||||
}
|
||||
|
||||
@@ -10,16 +10,16 @@
|
||||
"assets": {
|
||||
"macos-aarch64": {
|
||||
"urls": [
|
||||
"https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-universal.app.zip",
|
||||
"https://github.com/since2006/shell-rs/releases/download/v0.2.0/ShellRS-0.2.0-macos-universal.app.zip"
|
||||
"https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-aarch64.app.zip",
|
||||
"https://github.com/since2006/shell-rs/releases/download/v0.2.0/ShellRS-0.2.0-macos-aarch64.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",
|
||||
"https://github.com/since2006/shell-rs/releases/download/v0.2.0/ShellRS-0.2.0-macos-universal.app.zip"
|
||||
"https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-x86_64.app.zip",
|
||||
"https://github.com/since2006/shell-rs/releases/download/v0.2.0/ShellRS-0.2.0-macos-x86_64.app.zip"
|
||||
],
|
||||
"size": 41234567,
|
||||
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
@@ -42,8 +42,8 @@
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"macos-aarch64": "https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-aarch64.dmg",
|
||||
"macos-x86_64": "https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-x86_64.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"
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ pub enum Counter {
|
||||
NoticeKeyword,
|
||||
/// A `shellrs` command served.
|
||||
CliExec,
|
||||
CliExecTerminal,
|
||||
CliUpload,
|
||||
CliDownload,
|
||||
CliSync,
|
||||
@@ -95,7 +96,7 @@ pub enum Counter {
|
||||
}
|
||||
|
||||
impl Counter {
|
||||
pub const ALL: [Counter; 43] = [
|
||||
pub const ALL: [Counter; 44] = [
|
||||
Self::Ssh,
|
||||
Self::SshJump,
|
||||
Self::SshProxy,
|
||||
@@ -130,6 +131,7 @@ impl Counter {
|
||||
Self::NoticeBell,
|
||||
Self::NoticeKeyword,
|
||||
Self::CliExec,
|
||||
Self::CliExecTerminal,
|
||||
Self::CliUpload,
|
||||
Self::CliDownload,
|
||||
Self::CliSync,
|
||||
@@ -177,6 +179,7 @@ impl Counter {
|
||||
Self::NoticeBell => "notice_bell",
|
||||
Self::NoticeKeyword => "notice_keyword",
|
||||
Self::CliExec => "cli_exec",
|
||||
Self::CliExecTerminal => "cli_exec_terminal",
|
||||
Self::CliUpload => "cli_upload",
|
||||
Self::CliDownload => "cli_download",
|
||||
Self::CliSync => "cli_sync",
|
||||
|
||||
+27
-2
@@ -487,7 +487,7 @@ fn same_keystroke(a: &Keystroke, b: &Keystroke) -> bool {
|
||||
/// Why a key cannot be a shortcut.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Refusal {
|
||||
/// A plain key: it would stop typing it.
|
||||
/// A key that would stop typing: a letter, a digit, or Shift with one.
|
||||
NeedsModifier,
|
||||
/// 切换到标签 1…9 takes a modifier with a digit.
|
||||
NeedsDigit,
|
||||
@@ -544,7 +544,10 @@ pub fn check(
|
||||
return Err(Refusal::NeedsDigit);
|
||||
}
|
||||
keystroke.key = "1".into();
|
||||
} else if !held && !is_function_key(&keystroke.key) {
|
||||
} else if !held
|
||||
&& !is_function_key(&keystroke.key)
|
||||
&& !(modifiers.shift && is_cluster_key(&keystroke.key))
|
||||
{
|
||||
return Err(Refusal::NeedsModifier);
|
||||
}
|
||||
let keys = keystroke.unparse();
|
||||
@@ -589,6 +592,17 @@ fn is_function_key(key: &str) -> bool {
|
||||
.is_some_and(|number| (1..=24).contains(&number))
|
||||
}
|
||||
|
||||
/// Insert, Delete, Home, End, Page Up and Page Down: they sit above the
|
||||
/// arrow keys and type nothing, so Shift with one of them does not stop
|
||||
/// typing the way Shift+J or Shift+= would. Alone they still go to the
|
||||
/// terminal or a list, as vim and the SFTP pane use them.
|
||||
fn is_cluster_key(key: &str) -> bool {
|
||||
matches!(
|
||||
key,
|
||||
"insert" | "delete" | "home" | "end" | "pageup" | "pagedown"
|
||||
)
|
||||
}
|
||||
|
||||
/// The keys of a shortcut, one cap each, in the platform's order: ⌃⌥⇧⌘
|
||||
/// on macOS, Ctrl Alt Shift Win elsewhere. A numbered shortcut's key
|
||||
/// shows as 1…9.
|
||||
@@ -1041,8 +1055,19 @@ mod tests {
|
||||
|
||||
assert_eq!(check(new_host, "j"), Err(Refusal::NeedsModifier));
|
||||
assert_eq!(check(new_host, "shift-j"), Err(Refusal::NeedsModifier));
|
||||
assert_eq!(check(new_host, "shift-="), Err(Refusal::NeedsModifier));
|
||||
assert_eq!(check(new_host, "shift-up"), Err(Refusal::NeedsModifier));
|
||||
assert_eq!(check(new_host, "insert"), Err(Refusal::NeedsModifier));
|
||||
assert_eq!(check(new_host, "f5"), Ok("f5".into()));
|
||||
assert_eq!(check(new_host, "alt-j"), Ok("alt-j".into()));
|
||||
for key in ["insert", "delete", "home", "end", "pageup", "pagedown"] {
|
||||
let keys = format!("shift-{key}");
|
||||
assert_eq!(check(new_host, &keys), Ok(keys), "{key}");
|
||||
}
|
||||
assert_eq!(
|
||||
check(shortcut("terminal-paste"), "shift-insert"),
|
||||
Ok("shift-insert".into())
|
||||
);
|
||||
// Taken by another shortcut working anywhere.
|
||||
let terminal = shortcut("new-local-terminal");
|
||||
assert_eq!(
|
||||
|
||||
+28
-1
@@ -32,6 +32,23 @@ shellrs exec <host-id> --stdin <<'EOF'
|
||||
EOF
|
||||
```
|
||||
|
||||
## Hosts Opened From a Bastion Host
|
||||
|
||||
A host listed with `"temporary": true` may have been opened by a bastion host's link that allows one login. Then `exec`, `upload` and `download`, which log in again, fail with `missing_credential` or `connect_failed`, and the message says so. Run the command in the host's terminal open in ShellRS instead:
|
||||
|
||||
```bash
|
||||
shellrs exec <host-id> --terminal "<command>"
|
||||
shellrs exec <host-id> --terminal --stdin <<'EOF'
|
||||
<command>
|
||||
EOF
|
||||
```
|
||||
|
||||
- It types into the user's own terminal tab, in sight of the user. It sends Ctrl-C first, which interrupts whatever runs there: use it only on a tab the user handed to you, not on one they are working in.
|
||||
- The command runs where that terminal is now: its user, folder and machine, after any `su`, `sudo -i` or nested `ssh`. With `--json`, the result's `context` says where: `{"user", "host", "cwd", "shell", "interp"}`.
|
||||
- stderr comes as stdout. One command at a time per terminal. Only bash, dash and busybox ash.
|
||||
- Programs that ask for a password wait in the user's tab: use `sudo -n` and other non-interactive flags. Give your own tool a timeout; when the command is given up, it gets Ctrl-C.
|
||||
- Files cannot be transferred this way; write a small text file with a heredoc in the command.
|
||||
|
||||
## File Transfer
|
||||
|
||||
```bash
|
||||
@@ -94,6 +111,12 @@ $OutputEncoding = [Console]::OutputEncoding = [Text.UTF8Encoding]::new()
|
||||
'@ | shellrs exec <host-id> --stdin
|
||||
```
|
||||
|
||||
For `--terminal`, add `terminal = $true`:
|
||||
|
||||
```powershell
|
||||
@{ host = '<host-id>'; command = 'df -h'; terminal = $true } | ConvertTo-Json -Compress | shellrs exec --json | ConvertFrom-Json
|
||||
```
|
||||
|
||||
The `@'` must end its line and the `'@` must start one. Set the same encodings before the other commands, or non-ASCII text (such as the Chinese names of saved hosts) turns into `?` or mojibake.
|
||||
|
||||
To create or update a host or credential from Windows PowerShell 5.1, pipe the JSON in the same way, writing non-ASCII characters in it as `\uXXXX` escapes (`"group": "\u751f\u4ea7"` for 生产):
|
||||
@@ -125,6 +148,10 @@ Errors are printed to stderr with a code in brackets, such as `[not_enabled]`. E
|
||||
- `host_in_use`: the host's tabs are open in ShellRS. Ask the user whether to close them (`--force`).
|
||||
- `save_failed`: the change was made, but not all of it could be written to disk or the keychain. Tell the user the message.
|
||||
- `version_mismatch`: the `shellrs` command and the running ShellRS are different versions. Ask the user to restart or update ShellRS.
|
||||
- `host_key_unknown`, `missing_credential`: ShellRS has not connected to this host yet, or has no saved password. Ask the user to connect to the host once in ShellRS and save the password.
|
||||
- `host_key_unknown`, `missing_credential`: ShellRS has not connected to this host yet, or has no saved password. Ask the user to connect to the host once in ShellRS and save the password. For a host opened from a bastion host, use `--terminal` (see above).
|
||||
- `host_key_changed`: the server's host key changed. Tell the user; do not try to work around it.
|
||||
- `no_terminal` (`--terminal`): the host has no connected terminal in ShellRS. Ask the user to open or reconnect it.
|
||||
- `terminal_busy` (`--terminal`): a full-screen program has the terminal, another `--terminal` command still runs there, the shell did not answer, or it did not start the command (a bastion host's command filter may hold it back). Tell the user the message.
|
||||
- `unsupported_shell` (`--terminal`): the terminal runs a shell other than bash, dash or busybox ash. Tell the user.
|
||||
- `too_long` (`--terminal`): split the command into shorter ones.
|
||||
- `connect_failed` saying there is no permission to connect to ShellRS: the agent's sandbox blocks local inter-process communication. Ask the user to allow it, or run the command outside the sandbox.
|
||||
+21
-9
@@ -145,15 +145,27 @@ impl CliBackend for SshCliBackend {
|
||||
);
|
||||
Ok(signal_exit_code(&signal))
|
||||
}
|
||||
Err(error) => Err(CliError::new(
|
||||
match error.kind {
|
||||
ExecErrorKind::HostKeyUnknown => ErrorCode::HostKeyUnknown,
|
||||
ExecErrorKind::HostKeyChanged => ErrorCode::HostKeyChanged,
|
||||
ExecErrorKind::MissingCredential => ErrorCode::MissingCredential,
|
||||
ExecErrorKind::Connect | ExecErrorKind::Aborted => ErrorCode::ConnectFailed,
|
||||
},
|
||||
error.message,
|
||||
)),
|
||||
Err(error) => {
|
||||
// A bastion host's link logs in once; its terminal is open.
|
||||
let message = if target.login().shell_only
|
||||
&& matches!(
|
||||
error.kind,
|
||||
ExecErrorKind::MissingCredential | ExecErrorKind::Connect
|
||||
) {
|
||||
t!("cli.exec.try_terminal", message = error.message).to_string()
|
||||
} else {
|
||||
error.message
|
||||
};
|
||||
Err(CliError::new(
|
||||
match error.kind {
|
||||
ExecErrorKind::HostKeyUnknown => ErrorCode::HostKeyUnknown,
|
||||
ExecErrorKind::HostKeyChanged => ErrorCode::HostKeyChanged,
|
||||
ExecErrorKind::MissingCredential => ErrorCode::MissingCredential,
|
||||
ExecErrorKind::Connect | ExecErrorKind::Aborted => ErrorCode::ConnectFailed,
|
||||
},
|
||||
message,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+9
-1
@@ -15,7 +15,8 @@ use super::link::OpenLink;
|
||||
use super::protocol::{
|
||||
AuthChoice, CredentialDeleted, CredentialDetails, CredentialKindChoice, Envelope, ErrorCode,
|
||||
FrameKind, HostDeleted, HostDetails, HostInfo, PROTOCOL_VERSION, ProxyChoice, Reply, Request,
|
||||
RouteDetails, TransferCounters, TransferSummary, parse_json, read_frame, write_json,
|
||||
RouteDetails, TerminalContext, TransferCounters, TransferSummary, parse_json, read_frame,
|
||||
write_json,
|
||||
};
|
||||
use crate::host::HostOs;
|
||||
use crate::i18n::{t, tn};
|
||||
@@ -60,6 +61,9 @@ struct ExecResult<'a> {
|
||||
exit_code: i32,
|
||||
stdout: std::borrow::Cow<'a, str>,
|
||||
stderr: std::borrow::Cow<'a, str>,
|
||||
/// Where an `exec --terminal` command ran.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
context: Option<TerminalContext>,
|
||||
}
|
||||
|
||||
/// `value` as JSON in ASCII alone: every other character escaped, which
|
||||
@@ -233,6 +237,7 @@ where
|
||||
let mut progress = ProgressLine::default();
|
||||
// What `exec --json` holds until the command ends.
|
||||
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
|
||||
let mut context = None;
|
||||
loop {
|
||||
let Some((kind, payload)) = read_frame(&mut reader)? else {
|
||||
return Err(io::Error::from(io::ErrorKind::UnexpectedEof).into());
|
||||
@@ -286,11 +291,14 @@ where
|
||||
print_credential_deleted(&deleted, console)?;
|
||||
return Ok(0);
|
||||
}
|
||||
// Only `--json` prints where the command ran.
|
||||
Reply::Context(found) => context = Some(found),
|
||||
Reply::Exit { code } if console.exec_json => {
|
||||
let result = ExecResult {
|
||||
exit_code: code,
|
||||
stdout: String::from_utf8_lossy(&stdout),
|
||||
stderr: String::from_utf8_lossy(&stderr),
|
||||
context: context.take(),
|
||||
};
|
||||
writeln!(console.stdout, "{}", ascii_json(&result))?;
|
||||
return Ok(code);
|
||||
|
||||
+39
-16
@@ -40,13 +40,14 @@ pub use link::{OpenLink, link_arguments};
|
||||
pub use manage::CliChange;
|
||||
pub(crate) use manage::{
|
||||
CredentialPlan, GroupPlan, SecretChange, credential_details, credential_secrets,
|
||||
find_credential, find_saved_host, host_details, host_info, host_secrets, plan_credential,
|
||||
plan_host, with_saved_credential_secrets, with_saved_passwords,
|
||||
find_credential, find_host, find_saved_host, host_details, host_info, host_secrets,
|
||||
plan_credential, plan_host, with_saved_credential_secrets, with_saved_passwords,
|
||||
};
|
||||
pub use protocol::{
|
||||
AuthChoice, CliError, CredentialDeleted, CredentialDetails, CredentialFields,
|
||||
CredentialKindChoice, ErrorCode, HostDeleted, HostDetails, HostFields, HostInfo, ProxyChoice,
|
||||
Reply, Request, RouteDetails, RouteFields, Secret, TransferCounters, TransferSummary,
|
||||
Reply, Request, RouteDetails, RouteFields, Secret, TerminalContext, TransferCounters,
|
||||
TransferSummary,
|
||||
};
|
||||
pub use server::{ChangeReply, CliBackend, CliServer, CliTarget, CliUse};
|
||||
|
||||
@@ -135,9 +136,17 @@ enum Command {
|
||||
/// {"exit_code", "stdout", "stderr"} once the command ends, errors as
|
||||
/// {"error": {"code", "message"}}. The output is ASCII, everything
|
||||
/// else escaped: no shell quoting, and no console code page can
|
||||
/// garble it.
|
||||
/// garble it. {"terminal": true} is the same as --terminal.
|
||||
#[arg(long, conflicts_with_all = ["id", "command", "stdin"])]
|
||||
json: bool,
|
||||
/// Type the command into the host's terminal open in ShellRS
|
||||
/// instead of logging in again, for a bastion host that allows one
|
||||
/// login. It sends Ctrl-C first, which interrupts whatever runs
|
||||
/// there. Its stderr comes as stdout; with --json, the result also
|
||||
/// has "context": the user, host, folder and shell it ran in. Bash,
|
||||
/// dash and busybox ash only.
|
||||
#[arg(long)]
|
||||
terminal: bool,
|
||||
},
|
||||
/// Copy a local file or folder to a saved host.
|
||||
///
|
||||
@@ -326,22 +335,34 @@ fn request(command: Command, console: &mut Console) -> Result<Request, i32> {
|
||||
command,
|
||||
stdin,
|
||||
json,
|
||||
terminal,
|
||||
} => {
|
||||
// From here on, errors are JSON too.
|
||||
console.exec_json = json;
|
||||
let (host, command) = if json {
|
||||
let request = if json {
|
||||
exec_request(&read_stdin(console)?)
|
||||
.map_err(|message| console.error(ErrorCode::BadRequest, &message))?
|
||||
} else if stdin {
|
||||
(id.unwrap_or_default(), read_stdin(console)?)
|
||||
} else {
|
||||
(id.unwrap_or_default(), command.unwrap_or_default())
|
||||
ExecJson {
|
||||
host: id.unwrap_or_default(),
|
||||
command: if stdin {
|
||||
read_stdin(console)?
|
||||
} else {
|
||||
command.unwrap_or_default()
|
||||
},
|
||||
terminal: false,
|
||||
}
|
||||
};
|
||||
let command = normalize_command(&command);
|
||||
let command = normalize_command(&request.command);
|
||||
if command.trim().is_empty() {
|
||||
return Err(console.error(ErrorCode::BadRequest, &t!("cli.exec.empty")));
|
||||
}
|
||||
Request::Exec { host, command }
|
||||
let host = request.host;
|
||||
if terminal || request.terminal {
|
||||
Request::ExecInTerminal { host, command }
|
||||
} else {
|
||||
Request::Exec { host, command }
|
||||
}
|
||||
}
|
||||
Command::Upload {
|
||||
id,
|
||||
@@ -514,24 +535,26 @@ fn read_stdin(console: &mut Console) -> Result<String, i32> {
|
||||
}
|
||||
|
||||
/// What `exec --json` reads.
|
||||
#[derive(serde::Deserialize)]
|
||||
#[derive(Debug, PartialEq, serde::Deserialize)]
|
||||
struct ExecJson {
|
||||
host: String,
|
||||
command: String,
|
||||
/// `--terminal`.
|
||||
#[serde(default)]
|
||||
terminal: bool,
|
||||
}
|
||||
|
||||
/// The host and command of an `exec --json` request.
|
||||
fn exec_request(text: &str) -> Result<(String, String), String> {
|
||||
/// What an `exec --json` request asks for.
|
||||
fn exec_request(text: &str) -> Result<ExecJson, String> {
|
||||
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
|
||||
let request: ExecJson = serde_json::from_str(text).map_err(|error| {
|
||||
serde_json::from_str(text).map_err(|error| {
|
||||
t!(
|
||||
"cli.exec.bad_json",
|
||||
form = r#"{"host": ..., "command": ...}"#,
|
||||
error = error
|
||||
)
|
||||
.to_string()
|
||||
})?;
|
||||
Ok((request.host, request.command))
|
||||
})
|
||||
}
|
||||
|
||||
/// A command as the remote shell should see it. PowerShell ends every line
|
||||
|
||||
+43
-1
@@ -127,6 +127,14 @@ pub enum Request {
|
||||
host: String,
|
||||
command: String,
|
||||
},
|
||||
/// `exec --terminal`: type `command` into the host's open terminal
|
||||
/// instead of logging in again, for a bastion host that allows one
|
||||
/// login. Its output comes as stdout, then [`Reply::Context`], then
|
||||
/// [`Reply::Exit`].
|
||||
ExecInTerminal {
|
||||
host: String,
|
||||
command: String,
|
||||
},
|
||||
/// `source` is absolute: the app's working directory is not the
|
||||
/// caller's.
|
||||
Upload {
|
||||
@@ -207,6 +215,8 @@ pub enum Reply {
|
||||
Exit {
|
||||
code: i32,
|
||||
},
|
||||
/// Where an `exec --terminal` command runs, once it started.
|
||||
Context(TerminalContext),
|
||||
/// A host as it is now: shown, created or changed.
|
||||
Host(HostDetails),
|
||||
HostDeleted(HostDeleted),
|
||||
@@ -250,6 +260,19 @@ impl HostInfo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an `exec --terminal` command ran: the terminal may have gone to
|
||||
/// another user, folder or machine since it was opened.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TerminalContext {
|
||||
pub user: String,
|
||||
pub host: String,
|
||||
pub cwd: String,
|
||||
/// The interactive shell the command was typed into.
|
||||
pub shell: String,
|
||||
/// What ran the command: `bash`, or `sh` where there is none.
|
||||
pub interp: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TransferCounters {
|
||||
pub files: u64,
|
||||
@@ -296,6 +319,14 @@ pub enum ErrorCode {
|
||||
/// A change was made, but could not all be written to disk or the
|
||||
/// keychain.
|
||||
SaveFailed,
|
||||
/// `exec --terminal`: the host has no connected terminal.
|
||||
NoTerminal,
|
||||
/// `exec --terminal`: the terminal could not take the command.
|
||||
TerminalBusy,
|
||||
/// `exec --terminal`: the shell is not bash, dash or busybox ash.
|
||||
UnsupportedShell,
|
||||
/// `exec --terminal`: the command line is longer than the shell takes.
|
||||
TooLong,
|
||||
}
|
||||
|
||||
impl ErrorCode {
|
||||
@@ -314,6 +345,10 @@ impl ErrorCode {
|
||||
ErrorCode::CredentialNotFound => "credential_not_found",
|
||||
ErrorCode::HostInUse => "host_in_use",
|
||||
ErrorCode::SaveFailed => "save_failed",
|
||||
ErrorCode::NoTerminal => "no_terminal",
|
||||
ErrorCode::TerminalBusy => "terminal_busy",
|
||||
ErrorCode::UnsupportedShell => "unsupported_shell",
|
||||
ErrorCode::TooLong => "too_long",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -641,7 +676,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn error_codes_are_spelled_as_documented() {
|
||||
for code in [ErrorCode::NotEnabled, ErrorCode::HostKeyUnknown] {
|
||||
for code in [
|
||||
ErrorCode::NotEnabled,
|
||||
ErrorCode::HostKeyUnknown,
|
||||
ErrorCode::NoTerminal,
|
||||
ErrorCode::TerminalBusy,
|
||||
ErrorCode::UnsupportedShell,
|
||||
ErrorCode::TooLong,
|
||||
] {
|
||||
assert_eq!(
|
||||
serde_json::to_string(&code).unwrap(),
|
||||
format!("\"{}\"", code.as_str())
|
||||
|
||||
+156
-4
@@ -26,13 +26,16 @@ use super::manage::{
|
||||
};
|
||||
use super::protocol::{
|
||||
CliError, CredentialDetails, Envelope, ErrorCode, FrameKind, HostDetails, HostInfo,
|
||||
PROTOCOL_VERSION, Reply, Request, TransferCounters, TransferSummary, VersionOnly, parse_json,
|
||||
read_frame, write_frame, write_json,
|
||||
PROTOCOL_VERSION, Reply, Request, TerminalContext, TransferCounters, TransferSummary,
|
||||
VersionOnly, parse_json, read_frame, write_frame, write_json,
|
||||
};
|
||||
use crate::host::{Host, HostLogin, HostStore, matches_query};
|
||||
use crate::i18n::t;
|
||||
use crate::secrets::SecretRef;
|
||||
use crate::ssh::ExecStream;
|
||||
use crate::terminal::{
|
||||
BusyReason, RunEvent, RunFailure, RunHandle, RunProgress, RunTiming, TerminalRun,
|
||||
};
|
||||
|
||||
/// Does what a CLI request asks. Every method blocks: each request has a
|
||||
/// thread of its own.
|
||||
@@ -159,6 +162,26 @@ const QUEUE_TIMEOUT: Duration = if cfg!(test) {
|
||||
Duration::from_secs(10)
|
||||
};
|
||||
|
||||
/// How an `exec --terminal` run is paced; quick in the tests.
|
||||
const RUN_TIMING: RunTiming = if cfg!(test) {
|
||||
RunTiming {
|
||||
settle: Duration::ZERO,
|
||||
probe: Duration::from_millis(300),
|
||||
start: Duration::from_millis(300),
|
||||
tick: Duration::from_millis(20),
|
||||
}
|
||||
} else {
|
||||
RunTiming::STANDARD
|
||||
};
|
||||
|
||||
/// An `exec --terminal` run waiting for the app to find the host's
|
||||
/// terminal.
|
||||
struct PendingRun {
|
||||
id: u64,
|
||||
host: String,
|
||||
run: RunHandle,
|
||||
}
|
||||
|
||||
/// A change waiting for the app, with where its outcome goes.
|
||||
struct PendingChange {
|
||||
id: u64,
|
||||
@@ -189,6 +212,9 @@ struct Shared {
|
||||
/// Changes to hosts and credentials, oldest first, until the app takes
|
||||
/// them up: only the UI thread can change the store.
|
||||
changes: Mutex<VecDeque<PendingChange>>,
|
||||
/// `exec --terminal` runs, oldest first, until the app takes them up:
|
||||
/// only the UI thread can reach a terminal.
|
||||
runs: Mutex<VecDeque<PendingRun>>,
|
||||
next_change: AtomicU64,
|
||||
/// What kinds of command were served, for 匿名使用统计, until the app
|
||||
/// takes them.
|
||||
@@ -200,6 +226,7 @@ struct Shared {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CliUse {
|
||||
Exec,
|
||||
ExecTerminal,
|
||||
Upload,
|
||||
Download,
|
||||
Sync,
|
||||
@@ -211,6 +238,7 @@ impl CliUse {
|
||||
fn of(request: &Request) -> Option<Self> {
|
||||
match request {
|
||||
Request::Exec { .. } => Some(Self::Exec),
|
||||
Request::ExecInTerminal { .. } => Some(Self::ExecTerminal),
|
||||
Request::Upload { .. } => Some(Self::Upload),
|
||||
Request::Download { .. } => Some(Self::Download),
|
||||
Request::Sync { .. } => Some(Self::Sync),
|
||||
@@ -249,6 +277,7 @@ impl CliServer {
|
||||
backend,
|
||||
activation: Mutex::new(None),
|
||||
changes: Mutex::new(VecDeque::new()),
|
||||
runs: Mutex::new(VecDeque::new()),
|
||||
next_change: AtomicU64::new(0),
|
||||
usage: Mutex::new(Vec::new()),
|
||||
});
|
||||
@@ -297,6 +326,17 @@ impl CliServer {
|
||||
.map(|pending| (pending.change, pending.reply))
|
||||
}
|
||||
|
||||
/// The oldest `exec --terminal` run waiting, with the host it is for,
|
||||
/// for the app to hand to that host's terminal, or say why it cannot.
|
||||
pub fn take_run(&self) -> Option<(String, RunHandle)> {
|
||||
self.shared
|
||||
.runs
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.pop_front()
|
||||
.map(|pending| (pending.host, pending.run))
|
||||
}
|
||||
|
||||
/// Whether ShellRS was opened again since this was last asked, and the
|
||||
/// links it was opened with, oldest first: `Some` and empty for a plain
|
||||
/// re-open. The request threads cannot reach the window, so the app asks
|
||||
@@ -323,14 +363,23 @@ impl CliServer {
|
||||
}
|
||||
|
||||
impl Drop for CliServer {
|
||||
/// The app is going: whatever waits for it to take up a change hears
|
||||
/// that it never will.
|
||||
/// The app is going: whatever waits for it to take up a change or a
|
||||
/// run hears that it never will.
|
||||
fn drop(&mut self) {
|
||||
self.shared
|
||||
.changes
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.clear();
|
||||
for pending in self
|
||||
.shared
|
||||
.runs
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.drain(..)
|
||||
{
|
||||
pending.run.fail(RunFailure::NotConnected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,6 +689,10 @@ fn respond(
|
||||
})?;
|
||||
Ok(Reply::Exit { code })
|
||||
}
|
||||
Request::ExecInTerminal { host, command } => {
|
||||
let target = find(shared, &host)?;
|
||||
run_in_terminal(shared, writer, &target.info.id, command)
|
||||
}
|
||||
Request::Upload {
|
||||
host,
|
||||
source,
|
||||
@@ -790,6 +843,105 @@ fn change(shared: &Shared, change: CliChange) -> Result<Reply, CliError> {
|
||||
outcome.recv().unwrap_or_else(|_| Err(gone()))
|
||||
}
|
||||
|
||||
/// Hand a run to the app, which finds the host's terminal, and see it
|
||||
/// through: the command's output as stdout, its context, its exit code.
|
||||
/// Taken back, so certainly not typed, when the app does not take it up
|
||||
/// in time. While the command prints nothing, an empty frame now and then
|
||||
/// finds out whether the caller is still there; once it is not, the
|
||||
/// command gets a ^C.
|
||||
fn run_in_terminal(
|
||||
shared: &Shared,
|
||||
writer: &mut impl io::Write,
|
||||
host: &str,
|
||||
command: String,
|
||||
) -> Result<Reply, CliError> {
|
||||
let (run, events) = TerminalRun::new(command);
|
||||
let id = shared.next_change.fetch_add(1, Ordering::Relaxed);
|
||||
shared
|
||||
.runs
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.push_back(PendingRun {
|
||||
id,
|
||||
host: host.to_owned(),
|
||||
run: run.handle(),
|
||||
});
|
||||
let first = match events.recv_timeout(QUEUE_TIMEOUT) {
|
||||
Ok(event) => event,
|
||||
Err(_) => {
|
||||
let mut runs = shared
|
||||
.runs
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
if let Some(index) = runs.iter().position(|pending| pending.id == id) {
|
||||
runs.remove(index);
|
||||
return Err(CliError::new(
|
||||
ErrorCode::ConnectFailed,
|
||||
t!("cli.terminal.timed_out"),
|
||||
));
|
||||
}
|
||||
drop(runs);
|
||||
// Taken up just now: its answer is on the way.
|
||||
events
|
||||
.recv()
|
||||
.map_err(|_| run_error(RunFailure::NotConnected, host))?
|
||||
}
|
||||
};
|
||||
if let RunEvent::Failed(failure) = first {
|
||||
return Err(run_error(failure, host));
|
||||
}
|
||||
let code = run
|
||||
.drive(&events, RUN_TIMING, &mut |progress| match progress {
|
||||
RunProgress::Context(context) => write_json(
|
||||
writer,
|
||||
&Reply::Context(TerminalContext {
|
||||
user: context.user,
|
||||
host: context.host,
|
||||
cwd: context.cwd,
|
||||
shell: context.shell,
|
||||
interp: context.interp,
|
||||
}),
|
||||
),
|
||||
RunProgress::Output(bytes) => write_frame(writer, FrameKind::Stdout, &bytes),
|
||||
RunProgress::Idle => write_frame(writer, FrameKind::Stdout, &[]),
|
||||
})
|
||||
.map_err(|failure| run_error(failure, host))?;
|
||||
Ok(Reply::Exit { code })
|
||||
}
|
||||
|
||||
/// Why a run did not run, in the CLI's words.
|
||||
fn run_error(failure: RunFailure, host: &str) -> CliError {
|
||||
let busy = |message| CliError::new(ErrorCode::TerminalBusy, message);
|
||||
match failure {
|
||||
RunFailure::HostNotFound => host_not_found(host),
|
||||
RunFailure::NoTerminal => {
|
||||
CliError::new(ErrorCode::NoTerminal, t!("cli.terminal.no_terminal"))
|
||||
}
|
||||
RunFailure::NotConnected => {
|
||||
CliError::new(ErrorCode::NoTerminal, t!("cli.terminal.not_connected"))
|
||||
}
|
||||
RunFailure::UnsupportedShell(shell) if shell.is_empty() => CliError::new(
|
||||
ErrorCode::UnsupportedShell,
|
||||
t!("cli.terminal.unknown_shell"),
|
||||
),
|
||||
RunFailure::UnsupportedShell(shell) => CliError::new(
|
||||
ErrorCode::UnsupportedShell,
|
||||
t!("cli.terminal.unsupported_shell", shell = shell),
|
||||
),
|
||||
RunFailure::TooLong { limit } => CliError::new(
|
||||
ErrorCode::TooLong,
|
||||
t!("cli.terminal.too_long", limit = limit),
|
||||
),
|
||||
RunFailure::Busy(BusyReason::FullScreen) => busy(t!("cli.terminal.full_screen")),
|
||||
RunFailure::Busy(BusyReason::AnotherRun) => busy(t!("cli.terminal.another_run")),
|
||||
RunFailure::Busy(BusyReason::NoAnswer) => busy(t!("cli.terminal.no_answer")),
|
||||
RunFailure::Busy(BusyReason::NotStarted) => busy(t!("cli.terminal.not_started")),
|
||||
RunFailure::Disconnected => {
|
||||
CliError::new(ErrorCode::ConnectFailed, t!("cli.terminal.disconnected"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find(shared: &Shared, id: &str) -> Result<CliTarget, CliError> {
|
||||
shared
|
||||
.targets
|
||||
|
||||
+188
-3
@@ -19,7 +19,7 @@ use super::protocol::{
|
||||
};
|
||||
use super::server::{CliBackend, CliServer, CliTarget, CliUse};
|
||||
use super::{Cli, Command, CredentialsCommand, HostsCommand};
|
||||
use super::{ConsoleText, exec_request, normalize_command};
|
||||
use super::{ConsoleText, ExecJson, exec_request, normalize_command};
|
||||
use crate::app::cli_endpoint;
|
||||
use crate::host::{
|
||||
AuthKind, CredentialDraft, CredentialKind, GroupDraft, Host, HostDraft, HostId, HostStore,
|
||||
@@ -27,6 +27,7 @@ use crate::host::{
|
||||
};
|
||||
use crate::secrets::SecretRef;
|
||||
use crate::ssh::ExecStream;
|
||||
use crate::terminal::{RunFailure, RunHandle, TerminalTransportCommand};
|
||||
|
||||
/// Records what it was asked and answers from a script.
|
||||
#[derive(Default)]
|
||||
@@ -632,6 +633,19 @@ fn exec_takes_its_command_as_one_argument_or_from_stdin() {
|
||||
assert!(parse(&["shellrs", "exec", "--json", "--stdin"]).is_err());
|
||||
// Split words are a mistake, not a longer command.
|
||||
assert!(parse(&["shellrs", "exec", "ID", "ls", "-la"]).is_err());
|
||||
// Typed into the open terminal, in any of the forms.
|
||||
assert!(matches!(
|
||||
parse(&["shellrs", "exec", "ID", "--terminal", "ls"]),
|
||||
Ok(Command::Exec { terminal: true, command: Some(command), .. }) if command == "ls"
|
||||
));
|
||||
assert!(matches!(
|
||||
parse(&["shellrs", "exec", "--json", "--terminal"]),
|
||||
Ok(Command::Exec {
|
||||
terminal: true,
|
||||
json: true,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -680,14 +694,23 @@ fn a_windows_console_gets_text_even_from_bytes_that_are_not_utf8() {
|
||||
|
||||
#[test]
|
||||
fn exec_json_reads_the_host_and_the_command() {
|
||||
let asked = |host: &str, command: &str, terminal| ExecJson {
|
||||
host: host.into(),
|
||||
command: command.into(),
|
||||
terminal,
|
||||
};
|
||||
assert_eq!(
|
||||
exec_request("\u{feff}{\"host\": \"ID\", \"command\": \"echo \\\"hi\\\" | wc -c\"}"),
|
||||
Ok(("ID".to_string(), "echo \"hi\" | wc -c".to_string()))
|
||||
Ok(asked("ID", "echo \"hi\" | wc -c", false))
|
||||
);
|
||||
// Escaped as an agent may write it in PowerShell 5.1, which pipes ASCII.
|
||||
assert_eq!(
|
||||
exec_request(r#"{"host":"ID","command":"grep \u751f\u4ea7 a.log"}"#),
|
||||
Ok(("ID".to_string(), "grep 生产 a.log".to_string()))
|
||||
Ok(asked("ID", "grep 生产 a.log", false))
|
||||
);
|
||||
assert_eq!(
|
||||
exec_request(r#"{"host":"ID","command":"ls","terminal":true}"#),
|
||||
Ok(asked("ID", "ls", true))
|
||||
);
|
||||
assert!(exec_request(r#"{"command":"ls"}"#).is_err());
|
||||
assert!(exec_request("ls -la").is_err());
|
||||
@@ -1110,3 +1133,165 @@ fn an_app_that_does_not_know_the_command_is_said_to_be_older() {
|
||||
.starts_with("shellrs: [version_mismatch] 正在运行的 ShellRS 不认识这个命令"),
|
||||
);
|
||||
}
|
||||
|
||||
/// The app's end of an `exec --terminal` run: wait for it to come in.
|
||||
fn take_run(server: &CliServer) -> (String, RunHandle) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
loop {
|
||||
if let Some(taken) = server.take_run() {
|
||||
return taken;
|
||||
}
|
||||
assert!(std::time::Instant::now() < deadline, "no run came in");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// A terminal taking `run` up, with a shell whose program is `shell`: it
|
||||
/// answers the probe, then the command line with `output` and `code`,
|
||||
/// slowly enough for the server to check on the caller meanwhile.
|
||||
/// Everything typed, once nothing more is.
|
||||
fn answer_run(
|
||||
run: RunHandle,
|
||||
shell: &'static str,
|
||||
output: &'static str,
|
||||
code: i32,
|
||||
) -> std::thread::JoinHandle<String> {
|
||||
let (input, typed) = std::sync::mpsc::channel();
|
||||
assert!(run.accept(input));
|
||||
std::thread::spawn(move || {
|
||||
let mut all = String::new();
|
||||
while let Ok(TerminalTransportCommand::Write(bytes)) =
|
||||
typed.recv_timeout(std::time::Duration::from_millis(500))
|
||||
{
|
||||
let line = String::from_utf8(bytes).unwrap();
|
||||
all.push_str(&line);
|
||||
// ` sh -c '<script>' sh NONCE …`
|
||||
let Some(at) = line.find("' sh ") else {
|
||||
continue;
|
||||
};
|
||||
let nonce = &line[at + 5..at + 17];
|
||||
if line.contains("6973;P;") {
|
||||
run.feed(format!("\x1b]6973;P;{nonce};{shell}\x07").as_bytes());
|
||||
} else if line.contains("6973;B;") {
|
||||
let context: String = "deploy\nasset\n/home/deploy\nbash"
|
||||
.bytes()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
run.feed(format!("\x1b]6973;B;{nonce};{context}\x07").as_bytes());
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
run.feed(format!("{output}\x1b]6973;E;{nonce};{code}\x07").as_bytes());
|
||||
}
|
||||
}
|
||||
all
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_typed_into_the_terminal_passes_on_its_output_and_where_it_ran() {
|
||||
let fixture = fixture();
|
||||
let exec = |json| {
|
||||
let socket = fixture.socket.clone();
|
||||
let request = Request::ExecInTerminal {
|
||||
host: id(&fixture.web),
|
||||
command: "ls /srv".into(),
|
||||
};
|
||||
std::thread::spawn(move || {
|
||||
if json {
|
||||
let (code, result, _) = run_exec_json(&socket, request);
|
||||
(code, result.to_string())
|
||||
} else {
|
||||
let (code, stdout, _) = run(&socket, request, false);
|
||||
(code, stdout)
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let command = exec(false);
|
||||
let (host, run) = take_run(&fixture.server);
|
||||
assert_eq!(host, id(&fixture.web));
|
||||
let terminal = answer_run(run, "/usr/bin/bash", "a\r\nb\r\n", 2);
|
||||
assert_eq!(command.join().unwrap(), (2, "a\nb\n".to_string()));
|
||||
assert!(terminal.join().unwrap().contains(" 'ls /srv'\r"));
|
||||
|
||||
let command = exec(true);
|
||||
let (_, run) = take_run(&fixture.server);
|
||||
answer_run(run, "/bin/busybox", "a\r\n", 0);
|
||||
let (code, result) = command.join().unwrap();
|
||||
assert_eq!(code, 0);
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&result).unwrap(),
|
||||
serde_json::json!({
|
||||
"exit_code": 0,
|
||||
"stdout": "a\n",
|
||||
"stderr": "",
|
||||
"context": {
|
||||
"user": "deploy",
|
||||
"host": "asset",
|
||||
"cwd": "/home/deploy",
|
||||
"shell": "busybox",
|
||||
"interp": "bash",
|
||||
},
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
fixture.server.take_usage(),
|
||||
[CliUse::ExecTerminal, CliUse::ExecTerminal]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_that_cannot_be_typed_says_why() {
|
||||
let fixture = fixture();
|
||||
let exec = || {
|
||||
let socket = fixture.socket.clone();
|
||||
let host = id(&fixture.web);
|
||||
std::thread::spawn(move || {
|
||||
let request = Request::ExecInTerminal {
|
||||
host,
|
||||
command: "true".into(),
|
||||
};
|
||||
let (code, _, stderr) = run(&socket, request, false);
|
||||
(code, String::from_utf8(stderr).unwrap())
|
||||
})
|
||||
};
|
||||
let refused = |command: std::thread::JoinHandle<(i32, String)>, code: &str| {
|
||||
let (exit, stderr) = command.join().unwrap();
|
||||
assert_eq!(exit, 255);
|
||||
assert!(
|
||||
stderr.starts_with(&format!("shellrs: [{code}] ")),
|
||||
"{stderr}"
|
||||
);
|
||||
stderr
|
||||
};
|
||||
|
||||
// No terminal for it.
|
||||
let command = exec();
|
||||
take_run(&fixture.server).1.fail(RunFailure::NoTerminal);
|
||||
refused(command, "no_terminal");
|
||||
|
||||
// A shell it cannot type into: nothing but the probe was typed.
|
||||
let command = exec();
|
||||
let terminal = answer_run(take_run(&fixture.server).1, "/usr/bin/zsh", "", 0);
|
||||
assert!(refused(command, "unsupported_shell").contains("zsh"));
|
||||
assert!(!terminal.join().unwrap().contains("6973;B;"));
|
||||
|
||||
// Nothing answers the probe.
|
||||
let command = exec();
|
||||
let (input, _typed) = std::sync::mpsc::channel();
|
||||
assert!(take_run(&fixture.server).1.accept(input));
|
||||
refused(command, "terminal_busy");
|
||||
|
||||
// Nobody takes it up: taken back, never typed.
|
||||
let command = exec();
|
||||
refused(command, "connect_failed");
|
||||
assert!(fixture.server.take_run().is_none());
|
||||
|
||||
// An unknown host never gets to the app.
|
||||
let socket = fixture.socket.clone();
|
||||
let request = Request::ExecInTerminal {
|
||||
host: "nope".into(),
|
||||
command: "true".into(),
|
||||
};
|
||||
assert_eq!(run(&socket, request, false).0, 255);
|
||||
assert!(fixture.server.take_run().is_none());
|
||||
}
|
||||
@@ -117,18 +117,12 @@ impl ExplorerPanel {
|
||||
.expect("workspace checked host");
|
||||
let endpoint = login.endpoint();
|
||||
let home = local_provider.home().to_string_lossy().into_owned();
|
||||
let places = local_provider
|
||||
.places()
|
||||
.into_iter()
|
||||
.map(|(place, path)| (place, path.to_string_lossy().into_owned()))
|
||||
.collect();
|
||||
let local = cx.new(|cx| {
|
||||
FilePane::new(
|
||||
PaneSide::Local,
|
||||
id,
|
||||
host_id,
|
||||
home.clone(),
|
||||
places,
|
||||
store.clone(),
|
||||
dispatch.clone(),
|
||||
window,
|
||||
@@ -141,7 +135,6 @@ impl ExplorerPanel {
|
||||
id,
|
||||
host_id,
|
||||
String::new(),
|
||||
Vec::new(),
|
||||
store.clone(),
|
||||
dispatch.clone(),
|
||||
window,
|
||||
|
||||
@@ -199,6 +199,43 @@ impl FileListing {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the widths the columns were dragged to: the table takes the
|
||||
/// columns again on each `refresh`, every listing among them.
|
||||
pub(super) fn set_widths(&mut self, widths: &[Pixels]) {
|
||||
for (column, width) in self.columns.iter_mut().zip(widths) {
|
||||
column.width = *width;
|
||||
}
|
||||
}
|
||||
|
||||
/// A click on a column's title sorts as one on its arrow does:
|
||||
/// descending, then ascending, then back to by name.
|
||||
fn cycle_sort(&mut self, col_ix: usize) {
|
||||
let sort = match self.sort {
|
||||
Some((ix, ColumnSort::Descending)) if ix == col_ix => ColumnSort::Ascending,
|
||||
Some((ix, ColumnSort::Ascending)) if ix == col_ix => ColumnSort::Default,
|
||||
_ => ColumnSort::Descending,
|
||||
};
|
||||
self.set_sort(col_ix, sort);
|
||||
}
|
||||
|
||||
/// Sort by a column, marking it on the columns too, which the table
|
||||
/// takes again on each `refresh`: otherwise the arrows would go back to
|
||||
/// unsorted with the next listing while the rows stayed sorted.
|
||||
fn set_sort(&mut self, col_ix: usize, sort: ColumnSort) {
|
||||
self.sort = match sort {
|
||||
ColumnSort::Default => None,
|
||||
sort => Some((col_ix, sort)),
|
||||
};
|
||||
for (ix, column) in self.columns.iter_mut().enumerate() {
|
||||
column.sort = Some(if ix == col_ix {
|
||||
sort
|
||||
} else {
|
||||
ColumnSort::Default
|
||||
});
|
||||
}
|
||||
self.apply_sort();
|
||||
}
|
||||
|
||||
/// The rows shown, in display order.
|
||||
pub fn rows(&self) -> &[FileEntry] {
|
||||
&self.rows
|
||||
@@ -406,13 +443,14 @@ impl TableDelegate for FileListing {
|
||||
}
|
||||
|
||||
/// `Column::text_right` is not applied by `DataTable`, so the size
|
||||
/// column aligns itself. A right-click on a title is recorded for the
|
||||
/// list's menu, which offers the size formats on 大小's, as WinSCP does.
|
||||
/// column aligns itself. A click on a title sorts by the column, as in
|
||||
/// WinSCP, rather than only one on the arrow. A right-click is recorded
|
||||
/// for the list's menu, which offers the size formats on 大小's.
|
||||
fn render_th(
|
||||
&mut self,
|
||||
col_ix: usize,
|
||||
_: &mut Window,
|
||||
_: &mut Context<TableState<Self>>,
|
||||
cx: &mut Context<TableState<Self>>,
|
||||
) -> impl IntoElement {
|
||||
let column = &self.columns[col_ix];
|
||||
let key = column.key.clone();
|
||||
@@ -428,6 +466,14 @@ impl TableDelegate for FileListing {
|
||||
*menu_hit.borrow_mut() = Some(MenuHit::Column(key.clone()));
|
||||
})
|
||||
.on_any_mouse_down(move |_, _, _| geometry.pressed.set(Pressed::Title))
|
||||
// The table keeps the arrows in its columns, which only a
|
||||
// `refresh` gives it again.
|
||||
.on_click(cx.listener(move |table, _, _, cx| {
|
||||
cx.stop_propagation();
|
||||
table.delegate_mut().cycle_sort(col_ix);
|
||||
table.refresh(cx);
|
||||
cx.notify();
|
||||
}))
|
||||
.child(column.name.clone())
|
||||
}
|
||||
|
||||
@@ -593,11 +639,7 @@ impl TableDelegate for FileListing {
|
||||
_: &mut Window,
|
||||
cx: &mut Context<TableState<Self>>,
|
||||
) {
|
||||
self.sort = match sort {
|
||||
ColumnSort::Default => None,
|
||||
sort => Some((col_ix, sort)),
|
||||
};
|
||||
self.apply_sort();
|
||||
self.set_sort(col_ix, sort);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
|
||||
+30
-14
@@ -28,7 +28,7 @@ use gpui_kit::component::{
|
||||
searchable_list::{SearchableGroup, SearchableListItem, SearchableVec},
|
||||
select::{Select, SelectEvent, SelectState},
|
||||
separator::Separator,
|
||||
table::{DataTable, TableDelegate as _, TableState},
|
||||
table::{DataTable, TableDelegate as _, TableEvent, TableState},
|
||||
v_flex,
|
||||
};
|
||||
use gpui_kit::prelude::FluentBuilder as _;
|
||||
@@ -132,7 +132,7 @@ pub struct PathChoice {
|
||||
title: SharedString,
|
||||
path: SharedString,
|
||||
depth: usize,
|
||||
home: bool,
|
||||
place: Option<Place>,
|
||||
side: PaneSide,
|
||||
}
|
||||
impl SearchableListItem for PathChoice {
|
||||
@@ -144,10 +144,10 @@ impl SearchableListItem for PathChoice {
|
||||
&self.path
|
||||
}
|
||||
fn render(&self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let icon = if self.home {
|
||||
Icon::new(CatalogIcon::House)
|
||||
} else {
|
||||
Icon::new(IconName::Folder)
|
||||
let icon = match self.place {
|
||||
Some(Place::Home) => Icon::new(CatalogIcon::House),
|
||||
Some(Place::Drive(..)) => Icon::new(IconName::HardDrive),
|
||||
_ => Icon::new(IconName::Folder),
|
||||
};
|
||||
h_flex()
|
||||
.gap_2()
|
||||
@@ -182,7 +182,8 @@ pub struct FilePane {
|
||||
host_id: HostId,
|
||||
pub(super) path: String,
|
||||
home: String,
|
||||
/// Well-known local places for the 目录列表 select; empty for remote.
|
||||
/// Well-known local places for the 目录列表 select, read with each
|
||||
/// local listing; empty for remote.
|
||||
places: Vec<(Place, String)>,
|
||||
table: Entity<TableState<FileListing>>,
|
||||
/// The path label part under the pointer, by the directory it opens.
|
||||
@@ -236,7 +237,6 @@ impl FilePane {
|
||||
explorer: ExplorerId,
|
||||
host_id: HostId,
|
||||
home: String,
|
||||
places: Vec<(Place, String)>,
|
||||
store: Entity<HostStore>,
|
||||
dispatch: FocusHandle,
|
||||
window: &mut Window,
|
||||
@@ -272,6 +272,11 @@ impl FilePane {
|
||||
}
|
||||
},
|
||||
),
|
||||
cx.subscribe(&table, |_, table, event: &TableEvent, cx| {
|
||||
if let TableEvent::ColumnWidthsChanged(widths) = event {
|
||||
table.update(cx, |table, _| table.delegate_mut().set_widths(widths));
|
||||
}
|
||||
}),
|
||||
cx.observe(&store, |_, _, cx| cx.notify()),
|
||||
cx.observe_global::<ShowHiddenFiles>(|pane, cx| pane.sync_hidden_files(cx)),
|
||||
// The column titles and the select's groups are kept by
|
||||
@@ -290,7 +295,7 @@ impl FilePane {
|
||||
host_id,
|
||||
path: home.clone(),
|
||||
home,
|
||||
places,
|
||||
places: Vec::new(),
|
||||
table,
|
||||
hovered_part: None,
|
||||
label_width: Rc::default(),
|
||||
@@ -496,6 +501,10 @@ impl FilePane {
|
||||
pub fn is_busy(&self) -> bool {
|
||||
self.busy
|
||||
}
|
||||
/// The places the 目录列表 select offers, as of the last local listing.
|
||||
pub fn places(&self) -> &[(Place, String)] {
|
||||
&self.places
|
||||
}
|
||||
/// Select this row once the next listing arrives.
|
||||
pub fn select_after_load(&mut self, name: String) {
|
||||
self.select_after_load = Some(name);
|
||||
@@ -935,7 +944,7 @@ impl FilePane {
|
||||
title: title.into(),
|
||||
path: path.into(),
|
||||
depth,
|
||||
home: false,
|
||||
place: None,
|
||||
side,
|
||||
}),
|
||||
),
|
||||
@@ -947,7 +956,7 @@ impl FilePane {
|
||||
title: place.title(),
|
||||
path: path.clone().into(),
|
||||
depth: 0,
|
||||
home: *place == Place::Home,
|
||||
place: Some(*place),
|
||||
side,
|
||||
}),
|
||||
),
|
||||
@@ -969,14 +978,21 @@ impl FilePane {
|
||||
) {
|
||||
let id = self.begin_load(intent, cx);
|
||||
self.load_task = Some(cx.spawn_in(window, async move |this, cx| {
|
||||
let result = cx
|
||||
let (result, places) = cx
|
||||
.background_spawn(async move {
|
||||
provider
|
||||
let result = provider
|
||||
.list(&PathBuf::from(path))
|
||||
.map_err(|e| t!("explorer.list.read_failed", error = e).to_string())
|
||||
.map_err(|e| t!("explorer.list.read_failed", error = e).to_string());
|
||||
let places = provider
|
||||
.places()
|
||||
.into_iter()
|
||||
.map(|(place, path)| (place, path.to_string_lossy().into_owned()))
|
||||
.collect();
|
||||
(result, places)
|
||||
})
|
||||
.await;
|
||||
let _ = this.update_in(cx, |this, window, cx| {
|
||||
this.places = places;
|
||||
this.apply_listing(id, result, window, cx)
|
||||
});
|
||||
}));
|
||||
|
||||
+10
-3
@@ -3,14 +3,16 @@ use gpui_kit::*;
|
||||
|
||||
use crate::explorer::{FileSizeFormat, ShowHiddenFiles};
|
||||
use crate::i18n::UiLocale;
|
||||
use crate::terminal::{TerminalColors, TerminalFont, TerminalHighlights, is_font_installed};
|
||||
use crate::terminal::{
|
||||
TerminalColors, TerminalFont, TerminalHighlights, TerminalInteraction, is_font_installed,
|
||||
};
|
||||
|
||||
use super::AppSettings;
|
||||
use super::app_theme::{ui_theme, ui_theme_name};
|
||||
|
||||
/// Bring the window in line with the settings: the theme chosen for the
|
||||
/// appearance, in the app and its terminals, the locale, the terminal font
|
||||
/// and highlight rules, the keyboard shortcuts, the SFTP size format and
|
||||
/// appearance, in the app and its terminals, the locale, the terminal font,
|
||||
/// mouse and highlight rules, the keyboard shortcuts, the SFTP size format and
|
||||
/// whether SFTP shows hidden files.
|
||||
/// Does nothing where they already agree, so it can run on every settings
|
||||
/// change and every change of the system appearance.
|
||||
@@ -63,6 +65,11 @@ pub fn apply(settings: AppSettings, window: &mut Window, cx: &mut App) {
|
||||
window.refresh();
|
||||
}
|
||||
|
||||
// Read when the mouse acts, so nothing is redrawn.
|
||||
if cx.try_global::<TerminalInteraction>() != Some(&settings.terminal_interaction) {
|
||||
cx.set_global(settings.terminal_interaction);
|
||||
}
|
||||
|
||||
// Compiled once here for every terminal, which observe it; only when
|
||||
// the rules change, not on every change of the system appearance.
|
||||
let highlight = settings.terminal_highlight;
|
||||
|
||||
+35
-1
@@ -7,7 +7,7 @@ use crate::explorer::{FileSizeFormat, ShowHiddenFiles};
|
||||
use crate::i18n::t;
|
||||
use crate::terminal::{
|
||||
DEFAULT_FONT_SIZE, DEFAULT_LINE_HEIGHT, FONT_SIZE_RANGE, HighlightRule, LINE_HEIGHT_RANGE,
|
||||
TerminalTheme, default_rules,
|
||||
RightClick, TerminalInteraction, TerminalTheme, default_rules,
|
||||
};
|
||||
use crate::update::Channel;
|
||||
|
||||
@@ -22,6 +22,8 @@ pub struct AppSettings {
|
||||
/// 外观 → 终端主题.
|
||||
pub terminal_theme: TerminalThemeSettings,
|
||||
pub terminal_font: TerminalFontSettings,
|
||||
/// 终端 → 终端交互.
|
||||
pub terminal_interaction: TerminalInteraction,
|
||||
pub external_cli: ExternalCliSettings,
|
||||
pub update: UpdateSettings,
|
||||
/// 关于 → 隐私.
|
||||
@@ -245,6 +247,26 @@ impl Choice for Channel {
|
||||
}
|
||||
}
|
||||
|
||||
impl Choice for RightClick {
|
||||
const ALL: &'static [Self] = &[Self::Nothing, Self::Paste, Self::Menu];
|
||||
|
||||
fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Nothing => "nothing",
|
||||
Self::Paste => "paste",
|
||||
Self::Menu => "menu",
|
||||
}
|
||||
}
|
||||
|
||||
fn label(self) -> SharedString {
|
||||
match self {
|
||||
Self::Nothing => t!("settings.right_click.nothing"),
|
||||
Self::Paste => t!("settings.right_click.paste"),
|
||||
Self::Menu => t!("settings.right_click.menu"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -435,6 +457,7 @@ mod tests {
|
||||
}
|
||||
check::<InterfaceLanguage>();
|
||||
check::<Appearance>();
|
||||
check::<RightClick>();
|
||||
assert_eq!(Appearance::from_key("sepia"), None);
|
||||
}
|
||||
|
||||
@@ -587,6 +610,17 @@ mod tests {
|
||||
assert!(settings.window.remember_size && !settings.window.remember_position);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminals_show_the_menu_and_copy_only_on_command_unless_the_file_says_otherwise() {
|
||||
let settings: AppSettings = serde_json::from_str("{}").unwrap();
|
||||
assert!(!settings.terminal_interaction.copy_on_select);
|
||||
assert_eq!(settings.terminal_interaction.right_click, RightClick::Menu);
|
||||
let settings: AppSettings =
|
||||
serde_json::from_str(r#"{"terminal_interaction":{"right_click":"paste"}}"#).unwrap();
|
||||
assert!(!settings.terminal_interaction.copy_on_select);
|
||||
assert_eq!(settings.terminal_interaction.right_click, RightClick::Paste);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_updates_are_on_unless_the_file_says_otherwise() {
|
||||
let settings: AppSettings = serde_json::from_str("{}").unwrap();
|
||||
|
||||
@@ -385,10 +385,51 @@ fn terminal_groups(panel: &SettingsPanel, cx: &App) -> Vec<SettingGroup> {
|
||||
})
|
||||
.keywords(keywords(t!("settings.terminal.preview_keywords"))),
|
||||
]),
|
||||
interaction_group(store),
|
||||
notification_group(store),
|
||||
]
|
||||
}
|
||||
|
||||
/// 终端 → 终端交互: what the mouse does besides selecting.
|
||||
fn interaction_group(store: &Entity<SettingsStore>) -> SettingGroup {
|
||||
let (reader, writer) = (store.clone(), store.clone());
|
||||
SettingGroup::new()
|
||||
.title(t!("settings.interaction.title"))
|
||||
.items([
|
||||
SettingItem::new(
|
||||
t!("settings.interaction.copy_on_select"),
|
||||
SettingField::switch(
|
||||
move |cx| {
|
||||
reader
|
||||
.read(cx)
|
||||
.settings()
|
||||
.terminal_interaction
|
||||
.copy_on_select
|
||||
},
|
||||
move |on, cx| {
|
||||
writer.update(cx, |store, cx| {
|
||||
store.update(
|
||||
|settings| settings.terminal_interaction.copy_on_select = on,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
},
|
||||
)
|
||||
.default_value(false),
|
||||
)
|
||||
.description(t!("settings.interaction.copy_on_select_description")),
|
||||
SettingItem::new(
|
||||
t!("settings.interaction.right_click"),
|
||||
choice_field(
|
||||
store,
|
||||
|settings| settings.terminal_interaction.right_click,
|
||||
|settings, action| settings.terminal_interaction.right_click = action,
|
||||
),
|
||||
)
|
||||
.description(t!("settings.interaction.right_click_description")),
|
||||
])
|
||||
}
|
||||
|
||||
/// 终端 → 通知: when the window is not in front, the system's notification;
|
||||
/// when another tab is, one in the window.
|
||||
fn notification_group(store: &Entity<SettingsStore>) -> SettingGroup {
|
||||
|
||||
+17
-48
@@ -6,7 +6,7 @@ use anyhow::{Result, anyhow, bail};
|
||||
use futures::StreamExt as _;
|
||||
use russh_sftp::{
|
||||
client::{RawSftpSession, error::Error},
|
||||
protocol::{FileAttributes, OpenFlags, Packet, StatusCode},
|
||||
protocol::{FileAttributes, OpenFlags, StatusCode},
|
||||
};
|
||||
use std::{
|
||||
pin::Pin,
|
||||
@@ -34,25 +34,29 @@ pub(crate) trait RemoteFs {
|
||||
async fn open_read(&self, path: &RemotePath) -> Result<String>;
|
||||
/// Up to `len` bytes at `offset`; may return fewer. `None` at end of file.
|
||||
async fn read(&self, handle: &str, offset: u64, len: u32) -> Result<Option<Vec<u8>>>;
|
||||
async fn sync(&self, handle: &str) -> Result<()>;
|
||||
async fn write(&self, handle: &str, offset: u64, bytes: Vec<u8>) -> Result<()>;
|
||||
async fn close(&self, handle: &str) -> Result<()>;
|
||||
/// Set the modification time and permission bits of a closed file by its
|
||||
/// path (`setstat`), as WinSCP does: a bastion host has been seen to
|
||||
/// refuse `fsetstat` on a file still open.
|
||||
async fn attributes(
|
||||
&self,
|
||||
handle: &str,
|
||||
path: &RemotePath,
|
||||
modified: Option<u32>,
|
||||
permissions: Option<u32>,
|
||||
) -> Result<()>;
|
||||
async fn mkdir(&self, path: &RemotePath) -> Result<()>;
|
||||
async fn symlink(&self, target: &str, path: &RemotePath) -> Result<()>;
|
||||
async fn readlink(&self, path: &RemotePath) -> Result<String>;
|
||||
async fn rename(&self, from: &RemotePath, to: &RemotePath, replace: bool) -> Result<()>;
|
||||
/// A plain `rename`, which fails when `to` is there: replacing a file
|
||||
/// moves the old one aside first, as WinSCP never sends
|
||||
/// `posix-rename@openssh.com` either.
|
||||
async fn rename(&self, from: &RemotePath, to: &RemotePath) -> Result<()>;
|
||||
async fn remove(&self, path: &RemotePath) -> Result<()>;
|
||||
/// Remove an empty directory.
|
||||
async fn rmdir(&self, path: &RemotePath) -> Result<()>;
|
||||
/// Set the permission bits of a path (`setstat`, which follows links).
|
||||
async fn set_permissions(&self, path: &RemotePath, permissions: u32) -> Result<()>;
|
||||
fn atomic_replace(&self) -> bool;
|
||||
}
|
||||
|
||||
pub(crate) struct SftpClient {
|
||||
@@ -61,8 +65,6 @@ pub(crate) struct SftpClient {
|
||||
/// Turns true once the server side of the SFTP stream has ended.
|
||||
closed: watch::Receiver<bool>,
|
||||
fingerprint: String,
|
||||
atomic_replace: bool,
|
||||
fsync: bool,
|
||||
}
|
||||
impl SftpClient {
|
||||
pub async fn connect(
|
||||
@@ -92,21 +94,14 @@ impl SftpClient {
|
||||
if version.version != 3 {
|
||||
bail!(t!("sftp.client.no_v3"));
|
||||
}
|
||||
let atomic_replace = version
|
||||
.extensions
|
||||
.get("posix-rename@openssh.com")
|
||||
.is_some_and(|v| v == "1");
|
||||
let fsync = version
|
||||
.extensions
|
||||
.get("fsync@openssh.com")
|
||||
.is_some_and(|v| v == "1");
|
||||
// No extension is used, even when offered: a bastion host that offers
|
||||
// `fsync@openssh.com` has been seen to end the whole session on it.
|
||||
// WinSCP sends none of them for a transfer.
|
||||
Ok(Self {
|
||||
raw,
|
||||
ssh,
|
||||
closed,
|
||||
fingerprint,
|
||||
atomic_replace,
|
||||
fsync,
|
||||
})
|
||||
}
|
||||
/// Only the Unix tests have a local `sftp-server` to talk to.
|
||||
@@ -281,12 +276,6 @@ impl RemoteFs for SftpClient {
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
async fn sync(&self, handle: &str) -> Result<()> {
|
||||
if self.fsync {
|
||||
self.raw.fsync(handle).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn write(&self, handle: &str, offset: u64, bytes: Vec<u8>) -> Result<()> {
|
||||
self.raw.write(handle, offset, bytes).await?;
|
||||
Ok(())
|
||||
@@ -298,13 +287,13 @@ impl RemoteFs for SftpClient {
|
||||
|
||||
async fn attributes(
|
||||
&self,
|
||||
handle: &str,
|
||||
path: &RemotePath,
|
||||
modified: Option<u32>,
|
||||
permissions: Option<u32>,
|
||||
) -> Result<()> {
|
||||
self.raw
|
||||
.fsetstat(
|
||||
handle,
|
||||
.setstat(
|
||||
path.as_str(),
|
||||
FileAttributes {
|
||||
mtime: modified,
|
||||
atime: modified,
|
||||
@@ -337,25 +326,8 @@ impl RemoteFs for SftpClient {
|
||||
.filename,
|
||||
)
|
||||
}
|
||||
async fn rename(&self, from: &RemotePath, to: &RemotePath, replace: bool) -> Result<()> {
|
||||
if replace {
|
||||
let mut payload = Vec::new();
|
||||
for value in [from.as_str(), to.as_str()] {
|
||||
payload.extend_from_slice(&(value.len() as u32).to_be_bytes());
|
||||
payload.extend_from_slice(value.as_bytes());
|
||||
}
|
||||
match self
|
||||
.raw
|
||||
.extended("posix-rename@openssh.com", payload)
|
||||
.await?
|
||||
{
|
||||
Packet::Status(status) if status.status_code == StatusCode::Ok => {}
|
||||
Packet::Status(status) => return Err(Error::Status(status).into()),
|
||||
_ => bail!(t!("sftp.client.bad_rename_reply")),
|
||||
}
|
||||
} else {
|
||||
self.raw.rename(from.as_str(), to.as_str()).await?;
|
||||
}
|
||||
async fn rename(&self, from: &RemotePath, to: &RemotePath) -> Result<()> {
|
||||
self.raw.rename(from.as_str(), to.as_str()).await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn remove(&self, path: &RemotePath) -> Result<()> {
|
||||
@@ -381,9 +353,6 @@ impl RemoteFs for SftpClient {
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
fn atomic_replace(&self) -> bool {
|
||||
self.atomic_replace
|
||||
}
|
||||
}
|
||||
fn metadata(attrs: &FileAttributes) -> FileMetadata {
|
||||
// Compare the whole type field: russh-sftp's `is_dir()` tests one bit,
|
||||
|
||||
+1
-4
@@ -381,10 +381,7 @@ pub(crate) async fn write_in_place<F: RemoteFs>(
|
||||
let message = format!("{error:#}");
|
||||
error.context(SaveFailure::Interrupted(message))
|
||||
};
|
||||
let written = match write_all(fs, &handle, bytes).await {
|
||||
Ok(()) => fs.sync(&handle).await,
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
let written = write_all(fs, &handle, bytes).await;
|
||||
let close = fs.close(&handle).await;
|
||||
written.map_err(interrupted)?;
|
||||
close.map_err(interrupted)?;
|
||||
|
||||
+61
-2
@@ -693,6 +693,18 @@ pub enum Place {
|
||||
Desktop,
|
||||
Documents,
|
||||
Downloads,
|
||||
/// A Windows drive by its letter, as WinSCP lists them: `C: 本地磁盘`.
|
||||
Drive(char, DriveKind),
|
||||
}
|
||||
|
||||
/// What a Windows drive is, which its title says.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DriveKind {
|
||||
Local,
|
||||
Removable,
|
||||
Network,
|
||||
Optical,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl Place {
|
||||
@@ -702,6 +714,16 @@ impl Place {
|
||||
Self::Desktop => t!("sftp.place.desktop"),
|
||||
Self::Documents => t!("sftp.place.documents"),
|
||||
Self::Downloads => t!("sftp.place.downloads"),
|
||||
Self::Drive(letter, kind) => {
|
||||
let drive = format!("{letter}:");
|
||||
match kind {
|
||||
DriveKind::Local => t!("sftp.place.drive_local", drive = drive),
|
||||
DriveKind::Removable => t!("sftp.place.drive_removable", drive = drive),
|
||||
DriveKind::Network => t!("sftp.place.drive_network", drive = drive),
|
||||
DriveKind::Optical => t!("sftp.place.drive_optical", drive = drive),
|
||||
DriveKind::Other => drive.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -710,8 +732,9 @@ impl Place {
|
||||
pub trait LocalDirectoryProvider: Send + Sync + 'static {
|
||||
fn home(&self) -> PathBuf;
|
||||
fn list(&self, path: &Path) -> Result<DirectoryListing>;
|
||||
/// Well-known folders for the 目录列表 select, home first. Looked up
|
||||
/// from the platform, without touching the file system.
|
||||
/// Well-known folders for the 目录列表 select, home first, then the
|
||||
/// drives on Windows. Read again with each listing, on the same
|
||||
/// background executor, so a drive plugged in since shows up.
|
||||
fn places(&self) -> Vec<(Place, PathBuf)> {
|
||||
Vec::new()
|
||||
}
|
||||
@@ -746,6 +769,41 @@ pub trait LocalDirectoryProvider: Send + Sync + 'static {
|
||||
) -> Result<FileStamp>;
|
||||
}
|
||||
pub type SharedLocalDirectoryProvider = std::sync::Arc<dyn LocalDirectoryProvider>;
|
||||
|
||||
/// Every drive letter in use, `A:\` to `Z:\`: without them the local pane
|
||||
/// could not leave the drive it started on.
|
||||
#[cfg(windows)]
|
||||
fn drives() -> Vec<(Place, PathBuf)> {
|
||||
use windows_sys::Win32::{
|
||||
Storage::FileSystem::{GetDriveTypeW, GetLogicalDrives},
|
||||
System::WindowsProgramming::{
|
||||
DRIVE_CDROM, DRIVE_FIXED, DRIVE_RAMDISK, DRIVE_REMOTE, DRIVE_REMOVABLE,
|
||||
},
|
||||
};
|
||||
// SAFETY: takes nothing and returns a bit mask, bit 0 for `A:`.
|
||||
let letters = unsafe { GetLogicalDrives() };
|
||||
(b'A'..=b'Z')
|
||||
.filter(|letter| letters & (1 << (letter - b'A')) != 0)
|
||||
.map(|letter| {
|
||||
let root = format!("{}:\\", char::from(letter));
|
||||
let wide: Vec<u16> = root.encode_utf16().chain([0]).collect();
|
||||
// SAFETY: `wide` is a null-terminated UTF-16 string that outlives
|
||||
// the call.
|
||||
let kind = match unsafe { GetDriveTypeW(wide.as_ptr()) } {
|
||||
DRIVE_FIXED | DRIVE_RAMDISK => DriveKind::Local,
|
||||
DRIVE_REMOVABLE => DriveKind::Removable,
|
||||
DRIVE_REMOTE => DriveKind::Network,
|
||||
DRIVE_CDROM => DriveKind::Optical,
|
||||
_ => DriveKind::Other,
|
||||
};
|
||||
(Place::Drive(char::from(letter), kind), PathBuf::from(root))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
fn drives() -> Vec<(Place, PathBuf)> {
|
||||
Vec::new()
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct SystemLocalDirectoryProvider;
|
||||
impl LocalDirectoryProvider for SystemLocalDirectoryProvider {
|
||||
@@ -761,6 +819,7 @@ impl LocalDirectoryProvider for SystemLocalDirectoryProvider {
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(place, path)| Some((place, path?)))
|
||||
.chain(drives())
|
||||
.collect()
|
||||
}
|
||||
fn list(&self, path: &Path) -> Result<DirectoryListing> {
|
||||
|
||||
@@ -18,7 +18,7 @@ pub(crate) async fn run<F: RemoteFs>(fs: &F, operation: &RemoteOperation) -> Res
|
||||
if fs.metadata(to).await?.is_some() {
|
||||
bail!(t!("sftp.error.exists", name = to.file_name()));
|
||||
}
|
||||
fs.rename(from, to, false).await
|
||||
fs.rename(from, to).await
|
||||
}
|
||||
RemoteOperation::CreateDirectory { path } => {
|
||||
if fs.metadata(path).await?.is_some() {
|
||||
|
||||
@@ -29,6 +29,7 @@ struct TestServer {
|
||||
interrupts: Arc<AtomicUsize>,
|
||||
hangup: Arc<tokio::sync::Notify>,
|
||||
unsupported: bool,
|
||||
bastion: bool,
|
||||
}
|
||||
struct Handler {
|
||||
directory: PathBuf,
|
||||
@@ -39,6 +40,8 @@ struct Handler {
|
||||
/// Ends an SFTP session while nothing is asked of it.
|
||||
hangup: Arc<tokio::sync::Notify>,
|
||||
unsupported: bool,
|
||||
/// Ends the SFTP session on what a bastion host refuses.
|
||||
bastion: bool,
|
||||
channels: HashMap<ChannelId, russh::Channel<server::Msg>>,
|
||||
}
|
||||
impl server::Server for TestServer {
|
||||
@@ -52,6 +55,7 @@ impl server::Server for TestServer {
|
||||
interrupts: self.interrupts.clone(),
|
||||
hangup: self.hangup.clone(),
|
||||
unsupported: self.unsupported,
|
||||
bastion: self.bastion,
|
||||
channels: HashMap::new(),
|
||||
}
|
||||
}
|
||||
@@ -120,6 +124,7 @@ impl server::Handler for Handler {
|
||||
let directory = self.directory.clone();
|
||||
let interrupts = self.interrupts.clone();
|
||||
let hangup = self.hangup.clone();
|
||||
let bastion = self.bastion;
|
||||
tokio::spawn(async move {
|
||||
let executable = if cfg!(target_os = "macos") {
|
||||
"/usr/libexec/sftp-server"
|
||||
@@ -149,6 +154,9 @@ impl server::Handler for Handler {
|
||||
}
|
||||
let mut bytes = vec![0; size as usize];
|
||||
reader.read_exact(&mut bytes).await?;
|
||||
if bastion && refused_by_bastion(&bytes) {
|
||||
return Err::<(), _>(std::io::Error::other("refused by the bastion host"));
|
||||
}
|
||||
// SSH_FXP_WRITE (6) for uploads, SSH_FXP_READ (5) for downloads.
|
||||
if matches!(bytes.first(), Some(&5) | Some(&6)) {
|
||||
writes += 1;
|
||||
@@ -192,7 +200,27 @@ impl Drop for Running {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
/// What a bastion host in front of OpenSSH was seen to refuse, though it
|
||||
/// passes on OpenSSH's offer of extensions: `SSH_FXP_FSETSTAT` (10), which it
|
||||
/// answered with a failure, and `fsync@openssh.com`, on which it ended the
|
||||
/// session. Here every `SSH_FXP_EXTENDED` (200) is refused: like WinSCP,
|
||||
/// ShellRS uses none to transfer or save a file.
|
||||
fn refused_by_bastion(packet: &[u8]) -> bool {
|
||||
matches!(packet.first(), Some(10 | 200))
|
||||
}
|
||||
async fn server(directory: PathBuf, interrupts: usize, unsupported: bool) -> Option<Running> {
|
||||
serve(directory, interrupts, unsupported, false).await
|
||||
}
|
||||
/// A server that ends the SFTP session on what a bastion host refuses.
|
||||
async fn bastion_server(directory: PathBuf) -> Option<Running> {
|
||||
serve(directory, 0, false, true).await
|
||||
}
|
||||
async fn serve(
|
||||
directory: PathBuf,
|
||||
interrupts: usize,
|
||||
unsupported: bool,
|
||||
bastion: bool,
|
||||
) -> Option<Running> {
|
||||
let listener = match tokio::net::TcpListener::bind(("127.0.0.1", 0)).await {
|
||||
Ok(l) => l,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||
@@ -215,6 +243,7 @@ async fn server(directory: PathBuf, interrupts: usize, unsupported: bool) -> Opt
|
||||
interrupts: interrupts.clone(),
|
||||
hangup: hangup.clone(),
|
||||
unsupported,
|
||||
bastion,
|
||||
};
|
||||
let key = russh::keys::PrivateKey::random(
|
||||
&mut russh::keys::key::safe_rng(),
|
||||
@@ -696,3 +725,109 @@ fn scp_style_transfers_land_where_scp_would_put_them() {
|
||||
assert!(!remote.join("missing").exists());
|
||||
});
|
||||
}
|
||||
|
||||
/// Behind a bastion host that refuses `fsetstat` and extensions, an upload,
|
||||
/// an upload over that file and an editor's save all finish on the one
|
||||
/// connection, and the upload keeps the local file's modification time.
|
||||
#[test]
|
||||
fn uploads_and_saves_get_past_a_bastion_host_that_refuses_fsetstat_and_extensions() {
|
||||
runtime().block_on(async {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let remote = temp.path().join("remote");
|
||||
std::fs::create_dir(&remote).unwrap();
|
||||
let Some(server) = bastion_server(remote.clone()).await else {
|
||||
return;
|
||||
};
|
||||
let worker = worker(server.port, temp.path());
|
||||
let home = loop {
|
||||
match next(&worker).await {
|
||||
SftpEvent::Connected { home } => break home,
|
||||
SftpEvent::Disconnected(e) => panic!("connect failed: {e}"),
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
let source = temp.path().join("README.md");
|
||||
let modified = std::time::UNIX_EPOCH + Duration::from_secs(1_500_000_000);
|
||||
let write_source = |bytes: &[u8]| {
|
||||
std::fs::write(&source, bytes).unwrap();
|
||||
std::fs::File::options()
|
||||
.write(true)
|
||||
.open(&source)
|
||||
.unwrap()
|
||||
.set_modified(modified)
|
||||
.unwrap();
|
||||
};
|
||||
let upload =
|
||||
|| SftpCommand::Upload(UploadRequest::new(vec![source.clone()], home.clone()).unwrap());
|
||||
let uploaded = remote.join("README.md");
|
||||
|
||||
write_source(b"first");
|
||||
let done = transfer(&worker, upload()).await;
|
||||
assert_eq!(done.phase(), TransferPhase::Completed);
|
||||
assert_eq!(std::fs::read(&uploaded).unwrap(), b"first");
|
||||
assert_eq!(
|
||||
std::fs::metadata(&uploaded).unwrap().modified().unwrap(),
|
||||
modified
|
||||
);
|
||||
|
||||
write_source(b"second");
|
||||
worker.commands.send(upload()).await.unwrap();
|
||||
loop {
|
||||
match next(&worker).await {
|
||||
SftpEvent::Question(question) => {
|
||||
assert_eq!(question.kind(), TransferQuestionKind::Conflict);
|
||||
worker
|
||||
.commands
|
||||
.send(SftpCommand::Answer {
|
||||
request_id: question.id(),
|
||||
answer: TransferAnswer::new(TransferChoice::Overwrite, false),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
SftpEvent::Progress(p) if p.phase() == TransferPhase::Completed => {
|
||||
assert_eq!(p.succeeded(), 1);
|
||||
break;
|
||||
}
|
||||
SftpEvent::Progress(p)
|
||||
if matches!(
|
||||
p.phase(),
|
||||
TransferPhase::Stopped | TransferPhase::Reconnecting
|
||||
) =>
|
||||
{
|
||||
panic!("the upload over the file broke off: {p:?}")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read(&uploaded).unwrap(), b"second");
|
||||
assert_eq!(
|
||||
std::fs::metadata(&uploaded).unwrap().modified().unwrap(),
|
||||
modified
|
||||
);
|
||||
|
||||
worker
|
||||
.commands
|
||||
.send(SftpCommand::WriteFile {
|
||||
request_id: 7,
|
||||
path: home.join("README.md").unwrap(),
|
||||
bytes: b"edited".to_vec(),
|
||||
expected: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
loop {
|
||||
match next(&worker).await {
|
||||
SftpEvent::FileWritten { request_id, result } => {
|
||||
assert_eq!(request_id, 7);
|
||||
result.unwrap();
|
||||
break;
|
||||
}
|
||||
SftpEvent::Disconnected(e) => panic!("the save broke off: {e}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read(&uploaded).unwrap(), b"edited");
|
||||
assert_eq!(server.connections.load(Ordering::SeqCst), 1);
|
||||
});
|
||||
}
|
||||
+74
-52
@@ -41,11 +41,10 @@ struct Remote {
|
||||
reads: Cell<usize>,
|
||||
short_reads: Cell<bool>,
|
||||
renames: Cell<usize>,
|
||||
atomic: bool,
|
||||
denied: RefCell<Option<String>>,
|
||||
}
|
||||
impl Remote {
|
||||
fn new(atomic: bool) -> Self {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
nodes: RefCell::new(BTreeMap::new()),
|
||||
fault: Cell::new(None),
|
||||
@@ -55,7 +54,6 @@ impl Remote {
|
||||
reads: Cell::new(0),
|
||||
short_reads: Cell::new(false),
|
||||
renames: Cell::new(0),
|
||||
atomic,
|
||||
denied: RefCell::new(None),
|
||||
}
|
||||
}
|
||||
@@ -225,9 +223,6 @@ impl RemoteFs for Remote {
|
||||
self.writes.set(self.writes.get() + 1);
|
||||
self.fail(Fault::Write(self.writes.get()))
|
||||
}
|
||||
async fn sync(&self, _: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn close(&self, _: &str) -> Result<()> {
|
||||
if self.fault.get() == Some(Fault::ChangeTargetOnClose) {
|
||||
self.fault.set(None);
|
||||
@@ -241,12 +236,12 @@ impl RemoteFs for Remote {
|
||||
}
|
||||
async fn attributes(
|
||||
&self,
|
||||
handle: &str,
|
||||
path: &RemotePath,
|
||||
modified: Option<u32>,
|
||||
permissions: Option<u32>,
|
||||
) -> Result<()> {
|
||||
let mut nodes = self.nodes.borrow_mut();
|
||||
let n = nodes.get_mut(handle).unwrap();
|
||||
let n = nodes.get_mut(path.as_str()).unwrap();
|
||||
n.metadata = FileMetadata::new(
|
||||
EntryKind::File,
|
||||
n.data.len() as u64,
|
||||
@@ -283,13 +278,13 @@ impl RemoteFs for Remote {
|
||||
async fn readlink(&self, path: &RemotePath) -> Result<String> {
|
||||
Ok(self.nodes.borrow()[path.as_str()].link.clone())
|
||||
}
|
||||
async fn rename(&self, from: &RemotePath, to: &RemotePath, replace: bool) -> Result<()> {
|
||||
async fn rename(&self, from: &RemotePath, to: &RemotePath) -> Result<()> {
|
||||
if self.fault.get() == Some(Fault::RejectRename(self.renames.get() + 1)) {
|
||||
self.fault.set(None);
|
||||
bail!("publish denied");
|
||||
}
|
||||
let mut nodes = self.nodes.borrow_mut();
|
||||
if !replace && nodes.contains_key(to.as_str()) {
|
||||
if nodes.contains_key(to.as_str()) {
|
||||
bail!("target exists");
|
||||
}
|
||||
let node = nodes
|
||||
@@ -331,9 +326,6 @@ impl RemoteFs for Remote {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn atomic_replace(&self) -> bool {
|
||||
self.atomic
|
||||
}
|
||||
}
|
||||
impl Remote {
|
||||
/// The path a link chain ends at, as a server's `open` follows it.
|
||||
@@ -496,7 +488,7 @@ fn recursive_upload_deduplicates_sources_and_preserves_empty_directories_and_lin
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
run(&mut batch, &remote, &answers.control).await.unwrap();
|
||||
assert_eq!(remote.bytes("/dest/中文 目录/large.bin"), data);
|
||||
assert_eq!(remote.bytes("/dest/中文 目录/零 字节"), Vec::<u8>::new());
|
||||
@@ -540,7 +532,7 @@ fn winscp_style_upload_finishes_without_a_verification_phase() {
|
||||
&answers.control,
|
||||
)
|
||||
.await;
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
|
||||
run(&mut batch, &remote, &answers.control).await.unwrap();
|
||||
tokio::task::yield_now().await;
|
||||
@@ -583,7 +575,7 @@ fn resume_uses_remote_filepart_size_and_preserves_old_target() {
|
||||
let data = vec![71; 150_000];
|
||||
std::fs::write(&source, &data).unwrap();
|
||||
let journal = Journal::new(tmp.path().join("journal"));
|
||||
let remote = Remote::new(false);
|
||||
let remote = Remote::new();
|
||||
remote.file("/dest/file", b"old file intact");
|
||||
remote.fault.set(Some(Fault::Write(2)));
|
||||
let answers = Answers::new(vec![TransferChoice::Overwrite]);
|
||||
@@ -633,7 +625,7 @@ fn filepart_larger_than_source_requires_explicit_restart() {
|
||||
let source = tmp.path().join("file");
|
||||
std::fs::write(&source, vec![1; 100_000]).unwrap();
|
||||
let journal = Journal::new(tmp.path().join("journal"));
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.fault.set(Some(Fault::Write(2)));
|
||||
let answers = Answers::new(vec![]);
|
||||
let mut first = batch(&source, journal.clone(), &answers.control).await;
|
||||
@@ -663,7 +655,7 @@ fn winscp_style_resume_does_not_prevalidate_the_source_version() {
|
||||
let data = vec![1; 100_000];
|
||||
std::fs::write(&source, &data).unwrap();
|
||||
let journal = Journal::new(tmp.path().join("journal"));
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.fault.set(Some(Fault::Write(2)));
|
||||
let answers = Answers::new(vec![]);
|
||||
let mut first = batch(&source, journal.clone(), &answers.control).await;
|
||||
@@ -700,7 +692,7 @@ fn winscp_style_resume_detects_filepart_without_a_local_record() {
|
||||
let source = tmp.path().join("file");
|
||||
let data = vec![3; 100_000];
|
||||
std::fs::write(&source, &data).unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/dest/file.filepart", &data[..32_768]);
|
||||
let answers = Answers::new(vec![TransferChoice::Resume]);
|
||||
let mut upload = batch(
|
||||
@@ -724,17 +716,12 @@ fn winscp_style_resume_detects_filepart_without_a_local_record() {
|
||||
#[test]
|
||||
fn lost_replies_at_backup_publish_and_close_are_reconciled() {
|
||||
runtime().block_on(async {
|
||||
for (atomic, fault) in [
|
||||
(true, Fault::Rename(1)),
|
||||
(false, Fault::Rename(1)),
|
||||
(false, Fault::Rename(2)),
|
||||
(false, Fault::Close),
|
||||
] {
|
||||
for fault in [Fault::Rename(1), Fault::Rename(2), Fault::Close] {
|
||||
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
|
||||
let source = tmp.path().join("file");
|
||||
std::fs::write(&source, b"new contents").unwrap();
|
||||
let journal = Journal::new(tmp.path().join("journal"));
|
||||
let remote = Remote::new(atomic);
|
||||
let remote = Remote::new();
|
||||
remote.file("/dest/file", b"original");
|
||||
remote.fault.set(Some(fault));
|
||||
let answers = Answers::new(vec![TransferChoice::Overwrite]);
|
||||
@@ -756,7 +743,7 @@ fn cancellation_retains_journal_and_skip_does_not_overwrite() {
|
||||
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
|
||||
let source = tmp.path().join("file");
|
||||
std::fs::write(&source, b"new").unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/dest/file", b"old");
|
||||
let answers = Answers::new(vec![TransferChoice::Skip]);
|
||||
let journal = Journal::new(tmp.path().join("journal"));
|
||||
@@ -783,7 +770,7 @@ fn directory_permission_error_is_reported_as_failure() {
|
||||
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
|
||||
let source = tmp.path().join("blocked");
|
||||
std::fs::create_dir(&source).unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
*remote.denied.borrow_mut() = Some("/dest/blocked".into());
|
||||
let answers = Answers::new(vec![TransferChoice::Skip]);
|
||||
let mut batch = batch(
|
||||
@@ -842,7 +829,7 @@ fn retrying_an_unreadable_folder_adds_only_its_files_to_the_upload() {
|
||||
set_mode(0o755);
|
||||
assert_eq!(batch.meter.progress.total_bytes(), 10_000);
|
||||
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
run(&mut batch, &remote, &answers.control).await.unwrap();
|
||||
assert_eq!(batch.meter.progress.failed(), 0);
|
||||
assert_eq!(batch.meter.progress.total_bytes(), 10_300);
|
||||
@@ -859,7 +846,7 @@ fn retrying_an_unreadable_folder_adds_only_its_files_to_the_download() {
|
||||
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
|
||||
let out = tmp.path().join("out");
|
||||
std::fs::create_dir(&out).unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.nodes.borrow_mut().insert(
|
||||
"/srv/folder".into(),
|
||||
Node {
|
||||
@@ -939,6 +926,41 @@ fn paths_and_journals_reject_unsafe_identity() {
|
||||
});
|
||||
}
|
||||
|
||||
/// Drives are titled as WinSCP does: the letter, then what kind it is.
|
||||
#[test]
|
||||
fn drives_are_titled_by_letter_and_kind() {
|
||||
assert_eq!(Place::Drive('C', DriveKind::Local).title(), "C: 本地磁盘");
|
||||
assert_eq!(
|
||||
Place::Drive('E', DriveKind::Removable).title(),
|
||||
"E: 可移动磁盘"
|
||||
);
|
||||
assert_eq!(
|
||||
Place::Drive('Z', DriveKind::Network).title(),
|
||||
"Z: 网络驱动器"
|
||||
);
|
||||
assert_eq!(
|
||||
Place::Drive('F', DriveKind::Optical).title(),
|
||||
"F: CD 驱动器"
|
||||
);
|
||||
assert_eq!(Place::Drive('G', DriveKind::Other).title(), "G:");
|
||||
}
|
||||
|
||||
/// Without the drives, the local pane could not leave the one it started on.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn local_listing_places_include_the_system_drive() {
|
||||
let system = std::env::var("SystemDrive").unwrap();
|
||||
let letter = system.chars().next().unwrap().to_ascii_uppercase();
|
||||
let places = SystemLocalDirectoryProvider.places();
|
||||
assert!(
|
||||
places.contains(&(
|
||||
Place::Drive(letter, DriveKind::Local),
|
||||
std::path::PathBuf::from(format!("{letter}:\\"))
|
||||
)),
|
||||
"{places:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// On Windows std canonicalizes to `\\?\C:\…`, which the pane showed as is.
|
||||
#[test]
|
||||
fn local_listing_reports_the_path_people_type() {
|
||||
@@ -1066,7 +1088,7 @@ fn failed_compatibility_publish_restores_old_target_and_reports_failure() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let source = temp.path().join("file");
|
||||
std::fs::write(&source, b"new").unwrap();
|
||||
let remote = Remote::new(false);
|
||||
let remote = Remote::new();
|
||||
remote.file("/dest/file", b"original");
|
||||
remote.fault.set(Some(Fault::RejectRename(2)));
|
||||
let answers = Answers::new(vec![TransferChoice::Overwrite, TransferChoice::Skip]);
|
||||
@@ -1097,7 +1119,7 @@ fn target_changed_during_upload_requires_another_conflict_answer() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let source = temp.path().join("file");
|
||||
std::fs::write(&source, b"new").unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/dest/file", b"original");
|
||||
remote.fault.set(Some(Fault::ChangeTargetOnClose));
|
||||
let answers = Answers::new(vec![TransferChoice::Overwrite, TransferChoice::Skip]);
|
||||
@@ -1140,7 +1162,7 @@ fn target_removed_during_upload_still_requires_confirmation() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let source = temp.path().join("file");
|
||||
std::fs::write(&source, b"new").unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/dest/file", b"old");
|
||||
remote.fault.set(Some(Fault::RemoveTargetOnClose));
|
||||
let answers = Answers::new(vec![TransferChoice::Overwrite, TransferChoice::Skip]);
|
||||
@@ -1170,7 +1192,7 @@ fn remote_path(path: &str) -> RemotePath {
|
||||
#[test]
|
||||
fn deleting_a_tree_removes_links_but_never_what_they_point_to() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.dir("/keep", 0o755);
|
||||
remote.file("/keep/important", b"data");
|
||||
remote.dir("/gone", 0o755);
|
||||
@@ -1198,7 +1220,7 @@ fn deleting_a_tree_removes_links_but_never_what_they_point_to() {
|
||||
#[test]
|
||||
fn recursive_permissions_skip_links_and_can_keep_directories_searchable() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.dir("/site", 0o700);
|
||||
remote.dir("/site/css", 0o700);
|
||||
remote.file("/site/css/a.css", b"a");
|
||||
@@ -1231,7 +1253,7 @@ fn recursive_permissions_skip_links_and_can_keep_directories_searchable() {
|
||||
#[test]
|
||||
fn rename_and_create_refuse_existing_names() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/a", b"a");
|
||||
remote.file("/b", b"b");
|
||||
let error = super::operations::run(
|
||||
@@ -1329,7 +1351,7 @@ fn download_copies_trees_links_and_empty_directories_in_order() {
|
||||
for short_reads in [false, true] {
|
||||
let out = tmp.path().join(format!("out-{short_reads}"));
|
||||
std::fs::create_dir(&out).unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.short_reads.set(short_reads);
|
||||
remote.dir("/srv/中文 目录", 0o755);
|
||||
remote.file("/srv/中文 目录/large.bin", &data);
|
||||
@@ -1385,7 +1407,7 @@ fn download_resumes_from_the_partial_file_after_a_lost_connection() {
|
||||
runtime().block_on(async {
|
||||
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
|
||||
let data: Vec<u8> = (0..300_000).map(|i| (i % 253) as u8).collect();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/big", &data);
|
||||
remote.fault.set(Some(Fault::Read(5)));
|
||||
let journal = DownloadJournal::new(tmp.path().join("journal"));
|
||||
@@ -1440,7 +1462,7 @@ fn download_asks_before_overwriting_and_keeps_the_replaced_permissions() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
}
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/file", b"new contents");
|
||||
let journal = DownloadJournal::new(tmp.path().join("journal"));
|
||||
let answers = Answers::new(vec![TransferChoice::Skip]);
|
||||
@@ -1484,7 +1506,7 @@ fn download_asks_before_overwriting_and_keeps_the_replaced_permissions() {
|
||||
fn a_changed_remote_file_is_not_resumed_and_discard_cleans_up() {
|
||||
runtime().block_on(async {
|
||||
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/file", &vec![7; 200_000]);
|
||||
remote.fault.set(Some(Fault::Read(4)));
|
||||
let journal = DownloadJournal::new(tmp.path().join("journal"));
|
||||
@@ -1554,7 +1576,7 @@ fn a_changed_remote_file_is_not_resumed_and_discard_cleans_up() {
|
||||
fn download_cancellation_keeps_the_partial_file() {
|
||||
runtime().block_on(async {
|
||||
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/file", &vec![1; 100_000]);
|
||||
let answers = Answers::new(vec![]);
|
||||
let mut batch = download_batch(
|
||||
@@ -1745,7 +1767,7 @@ fn an_scp_upload_takes_the_destination_name_and_keeps_the_execute_bits() {
|
||||
let source = tmp.path().join("build.sh");
|
||||
std::fs::write(&source, b"#!/bin/sh\n").unwrap();
|
||||
std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
|
||||
let request = UploadRequest::scp(source.clone(), remote_path("/dest/deploy.sh"))
|
||||
.resolved(remote_path("/dest"), Some("deploy.sh".into()));
|
||||
@@ -1797,7 +1819,7 @@ fn an_scp_download_takes_the_destination_name_and_keeps_the_execute_bits() {
|
||||
let tmp = tempfile::tempdir_in(std::env::temp_dir().canonicalize().unwrap()).unwrap();
|
||||
let out = tmp.path().join("out");
|
||||
std::fs::create_dir(&out).unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/srv/run.sh", b"#!/bin/sh\n");
|
||||
remote
|
||||
.nodes
|
||||
@@ -1856,7 +1878,7 @@ fn an_scp_download_takes_the_destination_name_and_keeps_the_execute_bits() {
|
||||
#[test]
|
||||
fn a_text_file_is_read_whole_through_short_and_reordered_reads() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
let text: String = (0..40_000).map(|i| format!("line {i}\n")).collect();
|
||||
remote.file("/etc/app.conf", text.as_bytes());
|
||||
remote.short_reads.set(true);
|
||||
@@ -1875,7 +1897,7 @@ fn a_text_file_is_read_whole_through_short_and_reordered_reads() {
|
||||
#[test]
|
||||
fn a_link_is_followed_to_the_file_it_points_at() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/srv/real.conf", b"old\n");
|
||||
remote.link("/etc/app.conf", "/srv/real.conf");
|
||||
let path = remote_path("/etc/app.conf");
|
||||
@@ -1896,7 +1918,7 @@ fn a_link_is_followed_to_the_file_it_points_at() {
|
||||
#[test]
|
||||
fn directories_large_files_and_binary_files_are_refused() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.dir("/etc", 0o755);
|
||||
remote.file("/var/big.log", &vec![b'x'; EDIT_LIMIT as usize + 1]);
|
||||
let mut binary = vec![b'a'; 1_000_000];
|
||||
@@ -1924,7 +1946,7 @@ fn directories_large_files_and_binary_files_are_refused() {
|
||||
#[test]
|
||||
fn a_save_writes_in_place_and_keeps_the_permissions() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
let long: Vec<u8> = (0..100_000u32).map(|i| (i % 251) as u8 + 1).collect();
|
||||
remote.file("/etc/app.conf", &long);
|
||||
let path = remote_path("/etc/app.conf");
|
||||
@@ -1952,7 +1974,7 @@ fn a_save_writes_in_place_and_keeps_the_permissions() {
|
||||
#[test]
|
||||
fn a_save_over_a_file_changed_or_removed_since_is_refused() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/etc/app.conf", b"mine\n");
|
||||
let path = remote_path("/etc/app.conf");
|
||||
let file = read_text(&remote, &path).await.unwrap();
|
||||
@@ -1980,7 +2002,7 @@ fn a_save_over_a_file_changed_or_removed_since_is_refused() {
|
||||
#[test]
|
||||
fn a_save_that_fails_after_truncating_is_interrupted() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.file("/etc/app.conf", &vec![b'a'; 200_000]);
|
||||
remote.fault.set(Some(Fault::Write(2)));
|
||||
let error = write_in_place(
|
||||
@@ -2123,7 +2145,7 @@ fn local_files_are_edited_in_place_through_links() {
|
||||
#[test]
|
||||
fn a_binary_file_is_read_whole_for_a_preview_within_its_limit() {
|
||||
runtime().block_on(async {
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
let image: Vec<u8> = (0..300_000u32).map(|i| (i % 256) as u8).collect();
|
||||
remote.file("/srv/photo.png", &image);
|
||||
remote.short_reads.set(true);
|
||||
@@ -2169,7 +2191,7 @@ fn a_sync_copies_only_what_changed_since_the_last() {
|
||||
.unwrap();
|
||||
std::fs::write(dist.join("assets/logo.png"), b"png").unwrap();
|
||||
std::os::unix::fs::symlink("index.html", dist.join("home.html")).unwrap();
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.dir("/srv", 0o755);
|
||||
|
||||
let answers = Answers::new(vec![]);
|
||||
@@ -2217,7 +2239,7 @@ fn a_sync_with_delete_removes_what_is_not_here_and_nothing_it_cannot_see() {
|
||||
std::fs::set_permissions(dist.join("locked"), std::fs::Permissions::from_mode(0o000))
|
||||
.unwrap();
|
||||
|
||||
let remote = Remote::new(true);
|
||||
let remote = Remote::new();
|
||||
remote.dir("/srv/app", 0o755);
|
||||
remote.file("/srv/app/keep.txt", b"k");
|
||||
remote.file("/srv/app/extra.txt", b"x");
|
||||
|
||||
+29
-37
@@ -532,25 +532,6 @@ impl UploadBatch {
|
||||
uploaded += length;
|
||||
self.meter.advance(length, uploaded, control);
|
||||
}
|
||||
control
|
||||
.run(
|
||||
fs.attributes(
|
||||
&handle,
|
||||
u32::try_from(source_metadata.modified_ns / 1_000_000_000).ok(),
|
||||
record
|
||||
.original
|
||||
.as_ref()
|
||||
.and_then(FileMetadata::permissions)
|
||||
.or_else(|| {
|
||||
self.preserve_mode
|
||||
.then(|| item.metadata.permissions())
|
||||
.flatten()
|
||||
.map(|mode| mode & 0o777)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
control.run(fs.sync(&handle)).await?;
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
@@ -559,7 +540,24 @@ impl UploadBatch {
|
||||
let close = fs.close(&handle).await;
|
||||
result?;
|
||||
close?;
|
||||
Ok(())
|
||||
control
|
||||
.run(
|
||||
fs.attributes(
|
||||
&record.temporary,
|
||||
u32::try_from(source_metadata.modified_ns / 1_000_000_000).ok(),
|
||||
record
|
||||
.original
|
||||
.as_ref()
|
||||
.and_then(FileMetadata::permissions)
|
||||
.or_else(|| {
|
||||
self.preserve_mode
|
||||
.then(|| item.metadata.permissions())
|
||||
.flatten()
|
||||
.map(|mode| mode & 0o777)
|
||||
}),
|
||||
),
|
||||
)
|
||||
.await
|
||||
}
|
||||
async fn matches_expected_item<F: RemoteFs>(
|
||||
&self,
|
||||
@@ -603,7 +601,7 @@ impl UploadBatch {
|
||||
if fs.metadata(&record.target).await?.is_none()
|
||||
&& fs.metadata(&record.backup).await?.is_some()
|
||||
{
|
||||
fs.rename(&record.backup, &record.target, false).await?;
|
||||
fs.rename(&record.backup, &record.target).await?;
|
||||
}
|
||||
bail!(t!("sftp.upload.publish_unconfirmed"));
|
||||
}
|
||||
@@ -635,24 +633,22 @@ impl UploadBatch {
|
||||
record.original = current;
|
||||
self.journal.save(record).await?;
|
||||
if let Some(source_metadata) = &record.source_metadata {
|
||||
let handle = control.run(fs.open(&record.temporary, false)).await?;
|
||||
let result = control
|
||||
control
|
||||
.run(fs.attributes(
|
||||
&handle,
|
||||
&record.temporary,
|
||||
u32::try_from(source_metadata.modified_ns / 1_000_000_000).ok(),
|
||||
record.original.as_ref().and_then(FileMetadata::permissions),
|
||||
))
|
||||
.await;
|
||||
let closed = fs.close(&handle).await;
|
||||
result?;
|
||||
closed?;
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
if backup.is_none() && record.original.is_some() && !fs.atomic_replace() {
|
||||
// Over a file, the old one is moved aside first and removed once
|
||||
// the new one is in its place: no `posix-rename@openssh.com`.
|
||||
if backup.is_none() && record.original.is_some() {
|
||||
record.phase = PublishPhase::BackingUp;
|
||||
self.journal.save(record).await?;
|
||||
control
|
||||
.run(fs.rename(&record.target, &record.backup, false))
|
||||
.run(fs.rename(&record.target, &record.backup))
|
||||
.await?;
|
||||
record.phase = PublishPhase::BackedUp;
|
||||
self.journal.save(record).await?;
|
||||
@@ -660,11 +656,7 @@ impl UploadBatch {
|
||||
record.phase = PublishPhase::Publishing;
|
||||
self.journal.save(record).await?;
|
||||
let result = control
|
||||
.run(fs.rename(
|
||||
&record.temporary,
|
||||
&record.target,
|
||||
fs.atomic_replace() && record.original.is_some() && backup.is_none(),
|
||||
))
|
||||
.run(fs.rename(&record.temporary, &record.target))
|
||||
.await;
|
||||
if let Err(error) = result {
|
||||
if !super::client::is_network_error(&error)
|
||||
@@ -672,7 +664,7 @@ impl UploadBatch {
|
||||
&& fs.metadata(&record.target).await?.is_none()
|
||||
&& fs.metadata(&record.backup).await?.is_some()
|
||||
{
|
||||
fs.rename(&record.backup, &record.target, false).await?;
|
||||
fs.rename(&record.backup, &record.target).await?;
|
||||
record.phase = PublishPhase::Ready;
|
||||
self.journal.save(record).await?;
|
||||
}
|
||||
@@ -701,7 +693,7 @@ impl UploadBatch {
|
||||
)?;
|
||||
if fs.metadata(&record.backup).await?.is_some() {
|
||||
if fs.metadata(&record.target).await?.is_none() {
|
||||
fs.rename(&record.backup, &record.target, false).await?;
|
||||
fs.rename(&record.backup, &record.target).await?;
|
||||
} else {
|
||||
bail!(t!("sftp.upload.backup_and_target"));
|
||||
}
|
||||
|
||||
+89
-21
@@ -950,10 +950,14 @@ async fn authenticate(
|
||||
}
|
||||
|
||||
if method == LoginMethod::Password || partial {
|
||||
// The saved password goes to the server once, by whichever method
|
||||
// asks for it first.
|
||||
let mut saved_offered = false;
|
||||
let mut saved_rejected = false;
|
||||
if methods.contains(&MethodKind::Password) {
|
||||
// Try what the host has saved before bothering anyone.
|
||||
let mut saved_rejected = false;
|
||||
if let Some(saved) = saved_secret(secrets, &login.password) {
|
||||
saved_offered = true;
|
||||
let result = handle
|
||||
.authenticate_password(user, saved.to_string())
|
||||
.await
|
||||
@@ -1001,8 +1005,20 @@ async fn authenticate(
|
||||
}
|
||||
}
|
||||
if methods.contains(&MethodKind::KeyboardInteractive) {
|
||||
// A password login whose server takes passwords only this way
|
||||
// (ESXi) is asked for it as a single hidden question. After a
|
||||
// password that got partway, the question is a second factor.
|
||||
let mut password =
|
||||
(method == LoginMethod::Password && !partial).then(|| KeyboardPassword {
|
||||
saved: if saved_offered {
|
||||
None
|
||||
} else {
|
||||
saved_secret(secrets, &login.password)
|
||||
},
|
||||
rejected: saved_rejected,
|
||||
});
|
||||
for _ in 0..AUTH_RETRIES {
|
||||
if keyboard_interactive(handle, user, broker).await? {
|
||||
if keyboard_interactive(handle, user, broker, password.as_mut()).await? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
@@ -1011,6 +1027,14 @@ async fn authenticate(
|
||||
bail!(t!("ssh.auth.failed"))
|
||||
}
|
||||
|
||||
/// What a password login brings to keyboard-interactive authentication.
|
||||
struct KeyboardPassword {
|
||||
/// The saved password, until it is sent.
|
||||
saved: Option<Zeroizing<String>>,
|
||||
/// The server refused the saved password.
|
||||
rejected: bool,
|
||||
}
|
||||
|
||||
type Agent = russh::keys::agent::client::AgentClient<
|
||||
Box<dyn russh::keys::agent::client::AgentStream + Send + Unpin>,
|
||||
>;
|
||||
@@ -1234,44 +1258,88 @@ async fn ask_one_secret(
|
||||
}
|
||||
}
|
||||
|
||||
/// One keyboard-interactive exchange, `false` when the server refuses it.
|
||||
///
|
||||
/// For a password login, the exchange's first single hidden question asks
|
||||
/// for the password: the saved one answers it unasked, as WinSCP's does,
|
||||
/// and a connection that cannot ask says the password is what it lacks.
|
||||
async fn keyboard_interactive(
|
||||
handle: &mut SshHandle,
|
||||
user: &str,
|
||||
broker: Asker<'_>,
|
||||
mut password: Option<&mut KeyboardPassword>,
|
||||
) -> Result<bool> {
|
||||
let mut response = handle
|
||||
.authenticate_keyboard_interactive_start(user, None)
|
||||
.await
|
||||
.map_err(|_| anyhow!(t!("ssh.auth.keyboard_start_failed")))?;
|
||||
let mut password_asked = false;
|
||||
let mut saved_sent = false;
|
||||
loop {
|
||||
match response {
|
||||
KeyboardInteractiveAuthResponse::Success => return Ok(true),
|
||||
KeyboardInteractiveAuthResponse::Failure { .. } => return Ok(false),
|
||||
KeyboardInteractiveAuthResponse::Failure { .. } => {
|
||||
if saved_sent && let Some(password) = password.as_deref_mut() {
|
||||
password.rejected = true;
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
KeyboardInteractiveAuthResponse::InfoRequest {
|
||||
name,
|
||||
instructions,
|
||||
prompts,
|
||||
} => {
|
||||
let fields = prompts
|
||||
.into_iter()
|
||||
.map(|prompt| ConnectionPromptField::new(prompt.prompt, prompt.echo))
|
||||
.collect();
|
||||
let reply = broker
|
||||
.ask_credential(
|
||||
MissingCredential::KeyboardInteractive,
|
||||
ConnectionPromptKind::authentication(name, instructions, fields),
|
||||
)
|
||||
.await?;
|
||||
let ConnectionPromptReply::Answers(answers) = reply else {
|
||||
bail!(t!("ssh.auth.keyboard_cancelled"))
|
||||
let asks_password = password.is_some()
|
||||
&& !password_asked
|
||||
&& matches!(prompts.as_slice(), [prompt] if !prompt.echo);
|
||||
password_asked |= asks_password;
|
||||
let saved = password
|
||||
.as_deref_mut()
|
||||
.filter(|_| asks_password)
|
||||
.and_then(|password| password.saved.take());
|
||||
let answers = if prompts.is_empty() {
|
||||
// Only something to read, which OpenSSH passes on from
|
||||
// PAM this way; the server waits for the empty reply.
|
||||
Vec::new()
|
||||
} else if let Some(saved) = saved {
|
||||
saved_sent = true;
|
||||
vec![saved.to_string()]
|
||||
} else {
|
||||
let rejected = password
|
||||
.as_deref()
|
||||
.is_some_and(|password| password.rejected);
|
||||
let need = if asks_password {
|
||||
MissingCredential::Password { rejected }
|
||||
} else {
|
||||
MissingCredential::KeyboardInteractive
|
||||
};
|
||||
let instructions = if !(asks_password && rejected) {
|
||||
instructions
|
||||
} else if instructions.trim().is_empty() {
|
||||
t!("ssh.auth.saved_password_rejected").to_string()
|
||||
} else {
|
||||
format!("{}\n{instructions}", t!("ssh.auth.saved_password_rejected"))
|
||||
};
|
||||
let fields = prompts
|
||||
.into_iter()
|
||||
.map(|prompt| ConnectionPromptField::new(prompt.prompt, prompt.echo))
|
||||
.collect();
|
||||
let reply = broker
|
||||
.ask_credential(
|
||||
need,
|
||||
ConnectionPromptKind::authentication(name, instructions, fields),
|
||||
)
|
||||
.await?;
|
||||
let ConnectionPromptReply::Answers(answers) = reply else {
|
||||
bail!(t!("ssh.auth.keyboard_cancelled"))
|
||||
};
|
||||
answers
|
||||
.into_iter()
|
||||
.map(|answer| answer.into_inner())
|
||||
.collect()
|
||||
};
|
||||
response = handle
|
||||
.authenticate_keyboard_interactive_respond(
|
||||
answers
|
||||
.into_iter()
|
||||
.map(|answer| answer.into_inner())
|
||||
.collect(),
|
||||
)
|
||||
.authenticate_keyboard_interactive_respond(answers)
|
||||
.await
|
||||
.map_err(|_| anyhow!(t!("ssh.auth.keyboard_failed")))?;
|
||||
}
|
||||
|
||||
+154
-3
@@ -416,6 +416,9 @@ mod tests {
|
||||
auth: TestAuth,
|
||||
/// What an exec channel writes back for a given command.
|
||||
probe_reply: fn(&str) -> Option<&'static str>,
|
||||
/// This connection answered [`TestAuth::KeyboardPassword`]'s
|
||||
/// password question correctly.
|
||||
password_answered: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -423,6 +426,8 @@ mod tests {
|
||||
Password,
|
||||
PublicKey(PublicKey),
|
||||
KeyboardInteractive,
|
||||
/// The password, through keyboard-interactive only, as ESXi takes it.
|
||||
KeyboardPassword,
|
||||
}
|
||||
|
||||
impl TestAuth {
|
||||
@@ -430,12 +435,50 @@ mod tests {
|
||||
let method = match self {
|
||||
Self::Password => MethodKind::Password,
|
||||
Self::PublicKey(_) => MethodKind::PublicKey,
|
||||
Self::KeyboardInteractive => MethodKind::KeyboardInteractive,
|
||||
Self::KeyboardInteractive | Self::KeyboardPassword => {
|
||||
MethodKind::KeyboardInteractive
|
||||
}
|
||||
};
|
||||
MethodSet::from(&[method][..])
|
||||
}
|
||||
}
|
||||
|
||||
impl TestServer {
|
||||
/// One round of ESXi's exchange: the password as a single hidden
|
||||
/// question, then, as OpenSSH's PAM can, a round with nothing to
|
||||
/// answer.
|
||||
fn password_question(&mut self, response: Option<server::Response<'_>>) -> server::Auth {
|
||||
let Some(response) = response else {
|
||||
self.password_answered = false;
|
||||
return server::Auth::Partial {
|
||||
name: Cow::Borrowed(""),
|
||||
instructions: Cow::Borrowed(""),
|
||||
prompts: Cow::Owned(vec![(Cow::Borrowed("Password: "), false)]),
|
||||
};
|
||||
};
|
||||
let answers: Vec<_> = response.collect();
|
||||
match answers.as_slice() {
|
||||
[answer] if answer.as_ref() == TEST_PASSWORD.as_bytes() => {
|
||||
self.password_answered = true;
|
||||
server::Auth::Partial {
|
||||
name: Cow::Borrowed(""),
|
||||
instructions: Cow::Borrowed("Password expires in 7 days"),
|
||||
prompts: Cow::Owned(Vec::new()),
|
||||
}
|
||||
}
|
||||
[] if self.password_answered => server::Auth::Accept,
|
||||
// Refused, with keyboard-interactive still on offer, as
|
||||
// OpenSSH does.
|
||||
_ => server::Auth::Reject {
|
||||
proceed_with_methods: Some(MethodSet::from(
|
||||
&[MethodKind::KeyboardInteractive][..],
|
||||
)),
|
||||
partial_success: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl server::Server for TestServer {
|
||||
type Handler = Self;
|
||||
|
||||
@@ -486,8 +529,10 @@ mod tests {
|
||||
_: &str,
|
||||
response: Option<server::Response<'a>>,
|
||||
) -> Result<server::Auth, Self::Error> {
|
||||
if !matches!(self.auth, TestAuth::KeyboardInteractive) {
|
||||
return Ok(server::Auth::UnsupportedMethod);
|
||||
match self.auth {
|
||||
TestAuth::KeyboardInteractive => {}
|
||||
TestAuth::KeyboardPassword => return Ok(self.password_question(response)),
|
||||
_ => return Ok(server::Auth::UnsupportedMethod),
|
||||
}
|
||||
let Some(mut response) = response else {
|
||||
return Ok(server::Auth::Partial {
|
||||
@@ -693,6 +738,7 @@ mod tests {
|
||||
state: server_state,
|
||||
auth,
|
||||
probe_reply,
|
||||
password_answered: false,
|
||||
};
|
||||
let running = server.run_on_socket(config, &listener);
|
||||
ready_tx.send(Ok((port, running.handle()))).unwrap();
|
||||
@@ -1919,6 +1965,111 @@ mod tests {
|
||||
assert!(saw_challenge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_connection_test_answers_a_keyboard_interactive_password_question() {
|
||||
let Some(server) = start_server(TestAuth::KeyboardPassword) else {
|
||||
eprintln!("loopback sockets are unavailable in this sandbox; skipping");
|
||||
return;
|
||||
};
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let known_hosts = directory.path().join("known_hosts");
|
||||
let test = |request| {
|
||||
test_login(
|
||||
request,
|
||||
&known_hosts,
|
||||
Arc::new(InMemorySecretStore::default()),
|
||||
true,
|
||||
)
|
||||
.0
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
test(login_request(server.port).with_password(TEST_PASSWORD)),
|
||||
Ok(())
|
||||
);
|
||||
assert_eq!(
|
||||
test(login_request(server.port).with_password("wrong")),
|
||||
Err("用户名或密码错误".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
test(login_request(server.port)),
|
||||
Err("未填写密码".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_answers_a_keyboard_interactive_password_question_with_the_saved_password() {
|
||||
let Some(server) = start_server(TestAuth::KeyboardPassword) else {
|
||||
eprintln!("loopback sockets are unavailable in this sandbox; skipping");
|
||||
return;
|
||||
};
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let known_hosts = directory.path().join("known_hosts");
|
||||
trust_server(server.port, &known_hosts);
|
||||
let host = password_host(server.port);
|
||||
let saved = |password: Option<&str>| {
|
||||
let keychain = Arc::new(InMemorySecretStore::default());
|
||||
if let Some(password) = password {
|
||||
keychain.set(&host.password_secret(), password).unwrap();
|
||||
}
|
||||
keychain
|
||||
};
|
||||
|
||||
let (result, stdout, _) = run_cli_command(&host, &known_hosts, saved(Some(TEST_PASSWORD)));
|
||||
assert_eq!(result, Ok(crate::ssh::ExecExit::Code(3)));
|
||||
assert_eq!(stdout, b"out");
|
||||
|
||||
for (password, message) in [
|
||||
(None, "没有保存密码"),
|
||||
(Some("wrong"), "保存的密码被服务器拒绝"),
|
||||
] {
|
||||
let (result, _, _) = run_cli_command(&host, &known_hosts, saved(password));
|
||||
let error = result.unwrap_err();
|
||||
assert_eq!(error.kind, crate::ssh::ExecErrorKind::MissingCredential);
|
||||
assert!(error.message.contains(message), "{}", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rejected_saved_password_asks_the_keyboard_interactive_question_again() {
|
||||
let Some(server) = start_server(TestAuth::KeyboardPassword) else {
|
||||
eprintln!("loopback sockets are unavailable in this sandbox; skipping");
|
||||
return;
|
||||
};
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let host = password_host(server.port);
|
||||
let keychain = Arc::new(InMemorySecretStore::default());
|
||||
keychain.set(&host.password_secret(), "wrong").unwrap();
|
||||
|
||||
let report = connect_with_secrets(
|
||||
host,
|
||||
&directory.path().join("known_hosts"),
|
||||
keychain,
|
||||
|prompt| match prompt {
|
||||
ConnectionPromptKind::UnknownHost(_) => ConnectionPromptReply::TrustAndSave,
|
||||
ConnectionPromptKind::Authentication(authentication) => {
|
||||
assert_eq!(
|
||||
authentication.instructions(),
|
||||
"已保存的密码被服务器拒绝,请重新输入"
|
||||
);
|
||||
assert_eq!(authentication.fields().len(), 1);
|
||||
assert_eq!(authentication.fields()[0].label(), "Password: ");
|
||||
assert!(!authentication.fields()[0].echo());
|
||||
ConnectionPromptReply::Answers(vec![ConnectionSecret::new(TEST_PASSWORD)])
|
||||
}
|
||||
other => panic!("unexpected prompt: {other:?}"),
|
||||
},
|
||||
);
|
||||
// Asked once, for the password; the round with nothing to answer
|
||||
// after it is answered without asking.
|
||||
let asked = report
|
||||
.prompts
|
||||
.iter()
|
||||
.filter(|prompt| matches!(prompt, ConnectionPromptKind::Authentication(_)))
|
||||
.count();
|
||||
assert_eq!(asked, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_host_key_is_reported_and_blocked() {
|
||||
let Some(server) = start_server(TestAuth::Password) else {
|
||||
|
||||
@@ -30,6 +30,7 @@ use super::highlight::{
|
||||
};
|
||||
use super::links::{LinkMarker, url_search, visible_links};
|
||||
use super::notices::{NoticeScanner, ProgramNotice, TerminalNotice};
|
||||
use super::run::{BusyReason, RunFailure, RunHandle};
|
||||
use super::search::{MatchMarker, SearchDirection, SearchMark, SearchPosition, TerminalSearch};
|
||||
use super::{
|
||||
ExecRequest, ExecResult, SharedTerminalTransportFactory, TerminalColors, TerminalLifecycle,
|
||||
@@ -62,6 +63,8 @@ pub struct TerminalSnapshot {
|
||||
pub cursor_shape: CursorShape,
|
||||
pub cursor_blinking: bool,
|
||||
pub display_offset: usize,
|
||||
/// The lines above the screen, which the view can scroll back through.
|
||||
pub history_size: usize,
|
||||
pub colors: [Option<Rgb>; COUNT],
|
||||
/// The addresses of the links on screen, by the cells' `link`.
|
||||
pub links: Vec<gpui_kit::SharedString>,
|
||||
@@ -359,6 +362,11 @@ impl TerminalEngine {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Scrolls the view without a context, for the scrollbar's drag.
|
||||
pub fn scrollback(&self) -> Scrollback {
|
||||
Scrollback(self.runtime.term.clone())
|
||||
}
|
||||
|
||||
pub fn start_selection(
|
||||
&mut self,
|
||||
point: Point,
|
||||
@@ -493,6 +501,32 @@ impl TerminalEngine {
|
||||
self.runtime.exec(command)
|
||||
}
|
||||
|
||||
/// Take up an `exec --terminal` run (see [`super::TerminalRun`]): ^C to
|
||||
/// whatever has the terminal, the rest as the output comes. Refused
|
||||
/// while a full-screen program has the screen, which ^C does not end
|
||||
/// and the command line would be keys to, and while another run has
|
||||
/// the terminal, which the ^C would end.
|
||||
pub fn run_command(&self, run: RunHandle) -> Result<(), RunFailure> {
|
||||
if !self.lifecycle.accepts_input() {
|
||||
return Err(RunFailure::NotConnected);
|
||||
}
|
||||
if self.mode().contains(TermMode::ALT_SCREEN) {
|
||||
return Err(RunFailure::Busy(BusyReason::FullScreen));
|
||||
}
|
||||
{
|
||||
let mut slot = lock_run(&self.runtime.run);
|
||||
if slot.as_ref().is_some_and(RunHandle::is_active) {
|
||||
return Err(RunFailure::Busy(BusyReason::AnotherRun));
|
||||
}
|
||||
if !run.accept(self.runtime.commands.clone()) {
|
||||
return Ok(());
|
||||
}
|
||||
*slot = Some(run);
|
||||
}
|
||||
self.send_user_input(b"\x03".to_vec());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn status(&self) -> TerminalStatus {
|
||||
TerminalStatus::new(
|
||||
self.lifecycle.clone(),
|
||||
@@ -561,12 +595,35 @@ impl TerminalEngine {
|
||||
cursor_shape: content.cursor.shape,
|
||||
cursor_blinking: term.cursor_style().blinking,
|
||||
display_offset: content.display_offset,
|
||||
history_size: term.grid().history_size(),
|
||||
colors: std::array::from_fn(|index| content.colors[index]),
|
||||
links: links.into_links(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A terminal's view of its history, for the scrollbar, whose drag hands
|
||||
/// over a position and no context: whoever draws the terminal is told to
|
||||
/// redraw it. Taken for each frame, as a restart replaces the terminal.
|
||||
#[derive(Clone)]
|
||||
pub struct Scrollback(Arc<FairMutex<AlacrittyTerm>>);
|
||||
|
||||
impl Scrollback {
|
||||
/// Show the history from `lines` below its oldest line.
|
||||
pub fn scroll_to(&self, lines: usize) {
|
||||
scroll_to(&mut self.0.lock(), lines);
|
||||
}
|
||||
}
|
||||
|
||||
/// Lines are counted from the top, which stays put while output arrives
|
||||
/// below: the view stays where the pointer let it go.
|
||||
fn scroll_to(term: &mut AlacrittyTerm, lines: usize) {
|
||||
let grid = term.grid();
|
||||
let offset = grid.history_size().saturating_sub(lines);
|
||||
let delta = offset as i32 - grid.display_offset() as i32;
|
||||
term.scroll_display(Scroll::Delta(delta));
|
||||
}
|
||||
|
||||
/// The text of the line the cursor is on, or of the nearest line above it
|
||||
/// on screen that has any: where a program that rings for an answer has
|
||||
/// its question.
|
||||
@@ -636,6 +693,9 @@ struct TerminalRuntime {
|
||||
commands: mpsc::Sender<TerminalTransportCommand>,
|
||||
size: Arc<Mutex<TerminalSize>>,
|
||||
wakeup_pending: Arc<AtomicBool>,
|
||||
/// The `exec --terminal` run this connection carries, which the parser
|
||||
/// thread reads the output for. Given up with the connection.
|
||||
run: Arc<Mutex<Option<RunHandle>>>,
|
||||
}
|
||||
|
||||
impl TerminalRuntime {
|
||||
@@ -662,6 +722,8 @@ impl TerminalRuntime {
|
||||
let term = Arc::new(FairMutex::new(AlacrittyTerm::new(config, &size, proxy)));
|
||||
|
||||
let parser_term = term.clone();
|
||||
let run: Arc<Mutex<Option<RunHandle>>> = Arc::default();
|
||||
let parser_run = run.clone();
|
||||
thread::Builder::new()
|
||||
.name("shellrs-terminal-parser".into())
|
||||
.spawn(move || {
|
||||
@@ -711,6 +773,7 @@ impl TerminalRuntime {
|
||||
for notice in notices.scan(&bytes) {
|
||||
parser_proxy.send(TerminalUiEventKind::Notice(notice));
|
||||
}
|
||||
feed_run(&parser_run, &bytes);
|
||||
parser_proxy.wakeup();
|
||||
}
|
||||
TerminalTransportEvent::HostOsDetected(os) => {
|
||||
@@ -753,6 +816,9 @@ impl TerminalRuntime {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(run) = lock_run(&parser_run).take() {
|
||||
run.disconnected();
|
||||
}
|
||||
})
|
||||
.expect("terminal parser thread");
|
||||
|
||||
@@ -772,6 +838,7 @@ impl TerminalRuntime {
|
||||
commands,
|
||||
size: shared_size,
|
||||
wakeup_pending,
|
||||
run,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -971,6 +1038,21 @@ fn receive<T>(events: &async_channel::Receiver<T>, deadline: Option<Instant>) ->
|
||||
})
|
||||
}
|
||||
|
||||
/// Hand output to the run the connection carries, and let it go once it
|
||||
/// is over.
|
||||
fn feed_run(run: &Mutex<Option<RunHandle>>, bytes: &[u8]) {
|
||||
let mut slot = lock_run(run);
|
||||
if let Some(handle) = slot.as_ref()
|
||||
&& !handle.feed(bytes)
|
||||
{
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn lock_run(run: &Mutex<Option<RunHandle>>) -> std::sync::MutexGuard<'_, Option<RunHandle>> {
|
||||
run.lock().unwrap_or_else(|error| error.into_inner())
|
||||
}
|
||||
|
||||
/// Draw what an open synchronized update has held back, if its time is up.
|
||||
/// Says whether it was.
|
||||
fn end_overdue_sync(processor: &mut Processor, term: &mut AlacrittyTerm) -> bool {
|
||||
@@ -1540,6 +1622,21 @@ mod tests {
|
||||
assert_eq!(cells[2].search, SearchMark::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_scrollbar_counts_lines_from_the_top_of_the_history() {
|
||||
let mut term = test_term(8, 2);
|
||||
feed(&mut term, "1\r\n2\r\n3\r\n4\r\n5".as_bytes());
|
||||
assert_eq!(term.grid().history_size(), 3);
|
||||
|
||||
scroll_to(&mut term, 0);
|
||||
assert_eq!(term.grid().display_offset(), 3);
|
||||
scroll_to(&mut term, 2);
|
||||
assert_eq!(term.grid().display_offset(), 1);
|
||||
// Past the bottom is the bottom.
|
||||
scroll_to(&mut term, 10);
|
||||
assert_eq!(term.grid().display_offset(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_input_returns_to_bottom_and_clears_selection() {
|
||||
let mut term = test_term(8, 2);
|
||||
|
||||
+8
-1
@@ -10,6 +10,8 @@ mod local_terminal_panel;
|
||||
mod model;
|
||||
mod mouse;
|
||||
mod notices;
|
||||
mod run;
|
||||
mod scrollbar;
|
||||
mod search;
|
||||
mod terminal_panel;
|
||||
mod terminal_view;
|
||||
@@ -28,9 +30,14 @@ pub use highlight::{
|
||||
pub use local_pty::LocalPtyTransportFactory;
|
||||
pub use local_terminal_panel::{LocalTerminalPanel, LocalTerminalPanelEvent};
|
||||
pub use model::{
|
||||
LocalTerminalId, RemoteTerminalId, TerminalLifecycle, TerminalSize, TerminalStatus,
|
||||
LocalTerminalId, RemoteTerminalId, RightClick, TerminalInteraction, TerminalLifecycle,
|
||||
TerminalSize, TerminalStatus,
|
||||
};
|
||||
pub use notices::TerminalNotice;
|
||||
pub use run::{
|
||||
BusyReason, RunContext, RunEvent, RunFailure, RunHandle, RunProgress, RunShell, RunTiming,
|
||||
TerminalRun,
|
||||
};
|
||||
pub use search::{SearchDirection, SearchMark, SearchPosition};
|
||||
pub use terminal_panel::{TerminalPanel, TerminalPanelEvent};
|
||||
pub(crate) use terminal_view::terminal_key_bindings;
|
||||
|
||||
+37
-1
@@ -1,7 +1,8 @@
|
||||
use std::fmt;
|
||||
|
||||
use alacritty_terminal::grid::Dimensions;
|
||||
use gpui_kit::SharedString;
|
||||
use gpui_kit::{App, Global, SharedString};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::i18n::t;
|
||||
|
||||
@@ -142,3 +143,38 @@ impl TerminalStatus {
|
||||
self.rows
|
||||
}
|
||||
}
|
||||
|
||||
/// 终端 → 终端交互: what the mouse does in terminals besides selecting. A
|
||||
/// global, set from the settings, because terminals do not depend on them.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct TerminalInteraction {
|
||||
/// 选中复制: what a selection takes in goes to the clipboard once the
|
||||
/// mouse lets go. Off until the user turns it on.
|
||||
pub copy_on_select: bool,
|
||||
/// 右键行为.
|
||||
pub right_click: RightClick,
|
||||
}
|
||||
|
||||
impl Global for TerminalInteraction {}
|
||||
|
||||
impl TerminalInteraction {
|
||||
/// What the terminals do now.
|
||||
pub fn current(cx: &App) -> Self {
|
||||
cx.try_global::<Self>().copied().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// What right-clicking in a terminal does. A program that asked for the
|
||||
/// mouse never hears the right button.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RightClick {
|
||||
/// 无动作.
|
||||
Nothing,
|
||||
/// 粘贴: the clipboard, as ⌘V does.
|
||||
Paste,
|
||||
/// 显示菜单: copy, paste, find, clear and the owner's commands.
|
||||
#[default]
|
||||
Menu,
|
||||
}
|
||||
@@ -0,0 +1,694 @@
|
||||
//! 在已开的终端里执行: what `shellrs exec --terminal` types into an open
|
||||
//! remote terminal, and how the result is read back out of its output.
|
||||
//!
|
||||
//! A bastion host that opened ShellRS with a link serves one login, and
|
||||
//! ends the session when another channel opens on it (see
|
||||
//! `HostLogin::shell_only`). An agent's command can then only reach the
|
||||
//! host the way the user's own do: typed into the shell. A run goes:
|
||||
//!
|
||||
//! 1. ^C, which ends what runs in the foreground and empties the input
|
||||
//! line, in bash, dash and busybox ash alike. Nothing checks first what
|
||||
//! the terminal is doing: whoever hands a tab to an agent knows.
|
||||
//! 2. A moment later, a probe, whose answer (marker P) says the shell
|
||||
//! reads lines again and which shell it is.
|
||||
//! 3. Once P comes, the command line. It prints marker B with where it
|
||||
//! runs, then the command's output through a pipe, then marker E with
|
||||
//! the exit code.
|
||||
//!
|
||||
//! The markers are an OSC sequence alacritty ignores, with the run's
|
||||
//! nonce an argument of its own. The echo of a typed line has `\033` as
|
||||
//! four characters, never an escape, so only the run's own `printf` makes
|
||||
//! them.
|
||||
|
||||
use std::io;
|
||||
use std::sync::{Arc, Mutex, MutexGuard, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use rand::{Rng as _, distr::Alphanumeric};
|
||||
|
||||
use super::TerminalTransportCommand;
|
||||
|
||||
/// What every marker starts with: an OSC number no terminal uses.
|
||||
const MARKER: &[u8] = b"\x1b]6973;";
|
||||
/// The longest marker waited for: room for a context whose directory is
|
||||
/// as long as paths get, in hex.
|
||||
const MARKER_LIMIT: usize = 16 * 1024;
|
||||
const NONCE_LENGTH: usize = 12;
|
||||
|
||||
/// Prints P with the nonce and the program the interactive shell runs:
|
||||
/// `sh -c`'s parent is that shell. Not `/proc/<pid>/comm`, which says `sh`
|
||||
/// for a bash started as sh.
|
||||
const PROBE_SCRIPT: &str =
|
||||
r#"printf "\033]6973;P;%s;%s\007" "$1" "$(readlink /proc/$PPID/exe 2>/dev/null)""#;
|
||||
|
||||
/// Runs the command (`$2`, as `printf %b` reads it) with bash where there
|
||||
/// is one and sh where not, its output through a pipe so that programs
|
||||
/// see no terminal: no colors, no pager. B comes first with the user, the
|
||||
/// host, the directory and the interpreter, in hex so that no path can
|
||||
/// break the marker; E comes last, printed by this shell after the pipe.
|
||||
/// It survives a ^C (`trap : INT`) while the command, in a subshell whose
|
||||
/// trap is reset, does not; the status comes out on fd 3, and none means
|
||||
/// the command was interrupted. No `'`, `!` or `\\` in it: it is typed in
|
||||
/// single quotes.
|
||||
const COMMAND_SCRIPT: &str = concat!(
|
||||
r#"trap : INT; i=$(command -v bash || echo sh); "#,
|
||||
r#"c=$(printf "%s\n%s\n%s\n%s" "$(id -un)" "$(uname -n)" "$PWD" "${i##*/}" "#,
|
||||
r#"| od -An -tx1 2>/dev/null | tr -d " \n"); "#,
|
||||
r#"printf "\033]6973;B;%s;%s\007" "$1" "$c"; exec 4>&1; "#,
|
||||
r#"s=$( { { "$i" -c "$(printf %b "$2")" </dev/null 2>&1 3>&- 4>&-; printf %d $? >&3; } "#,
|
||||
r#"| cat >&4; } 3>&1 ); "#,
|
||||
r#"printf "\033]6973;E;%s;%s\007" "$1" "${s:-130}""#,
|
||||
);
|
||||
|
||||
/// The interactive shells a command can be typed into.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RunShell {
|
||||
Bash,
|
||||
Dash,
|
||||
Busybox,
|
||||
}
|
||||
|
||||
impl RunShell {
|
||||
/// The shell whose program is `exe`, as `/proc/<pid>/exe` reads.
|
||||
pub fn of(exe: &str) -> Option<Self> {
|
||||
match program_name(exe) {
|
||||
"bash" => Some(Self::Bash),
|
||||
"dash" => Some(Self::Dash),
|
||||
"busybox" => Some(Self::Busybox),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Bash => "bash",
|
||||
Self::Dash => "dash",
|
||||
Self::Busybox => "busybox",
|
||||
}
|
||||
}
|
||||
|
||||
/// The longest command line it takes, Enter included. The line is
|
||||
/// typed ahead while the probe still runs, when the terminal holds a
|
||||
/// line of 4095 bytes at most; busybox's own line editor holds 1024.
|
||||
pub fn line_limit(self) -> usize {
|
||||
match self {
|
||||
Self::Busybox => 1000,
|
||||
Self::Bash | Self::Dash => 4000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The file name of `exe`, without the mark Linux adds once the program
|
||||
/// was upgraded under a running shell.
|
||||
fn program_name(exe: &str) -> &str {
|
||||
let exe = exe.trim();
|
||||
let exe = exe.strip_suffix(" (deleted)").unwrap_or(exe);
|
||||
exe.rsplit('/').next().unwrap_or(exe)
|
||||
}
|
||||
|
||||
/// Where the command ran.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct RunContext {
|
||||
pub user: String,
|
||||
pub host: String,
|
||||
pub cwd: String,
|
||||
/// The interactive shell it was typed into.
|
||||
pub shell: String,
|
||||
/// What ran it: `bash`, or `sh` where there is no bash.
|
||||
pub interp: String,
|
||||
}
|
||||
|
||||
impl RunContext {
|
||||
/// The context from marker B's hex: user, host, directory and
|
||||
/// interpreter, a line each. A directory may hold newlines itself, so
|
||||
/// it is what is left between the first two and the last.
|
||||
fn read(hex: &[u8], shell: &str) -> Self {
|
||||
let bytes: Vec<u8> = hex
|
||||
.as_chunks::<2>()
|
||||
.0
|
||||
.iter()
|
||||
.filter_map(|pair| u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok())
|
||||
.collect();
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
let mut context = Self {
|
||||
shell: shell.to_owned(),
|
||||
..Self::default()
|
||||
};
|
||||
let mut parts = text.splitn(3, '\n');
|
||||
context.user = parts.next().unwrap_or_default().to_owned();
|
||||
context.host = parts.next().unwrap_or_default().to_owned();
|
||||
if let Some((cwd, interp)) = parts.next().unwrap_or_default().rsplit_once('\n') {
|
||||
context.cwd = cwd.to_owned();
|
||||
context.interp = interp.to_owned();
|
||||
}
|
||||
context
|
||||
}
|
||||
}
|
||||
|
||||
/// What a run tells the request it serves, in order.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum RunEvent {
|
||||
/// A terminal took the run up and sent its ^C.
|
||||
Accepted,
|
||||
/// The shell answered the probe and the command line was typed.
|
||||
Typed,
|
||||
/// The command started.
|
||||
Begun(RunContext),
|
||||
Output(Vec<u8>),
|
||||
/// The command ended with this exit code.
|
||||
Exited(i32),
|
||||
Failed(RunFailure),
|
||||
}
|
||||
|
||||
/// Why a run did not run, or did not finish.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum RunFailure {
|
||||
HostNotFound,
|
||||
/// The host has no terminal open.
|
||||
NoTerminal,
|
||||
/// Its terminals are not connected.
|
||||
NotConnected,
|
||||
/// The program of the interactive shell; empty when it could not be
|
||||
/// read (no `/proc`).
|
||||
UnsupportedShell(String),
|
||||
TooLong {
|
||||
limit: usize,
|
||||
},
|
||||
Busy(BusyReason),
|
||||
/// The terminal went away after the command was typed: it may have
|
||||
/// run, in part.
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum BusyReason {
|
||||
/// A full-screen program has the screen, which ^C does not end.
|
||||
FullScreen,
|
||||
/// Another run has the terminal.
|
||||
AnotherRun,
|
||||
/// Nothing answered the probe.
|
||||
NoAnswer,
|
||||
/// The shell did not start the command.
|
||||
NotStarted,
|
||||
}
|
||||
|
||||
/// How long each step of a run may take.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct RunTiming {
|
||||
/// After the ^C, for the shell to show its prompt again: bytes coming
|
||||
/// with the ^C may go with it.
|
||||
pub settle: Duration,
|
||||
pub probe: Duration,
|
||||
/// From typing the command to its marker B. A bastion host's command
|
||||
/// filter or review may hold it back.
|
||||
pub start: Duration,
|
||||
/// How often the caller hears of a run that prints nothing.
|
||||
pub tick: Duration,
|
||||
}
|
||||
|
||||
impl RunTiming {
|
||||
pub const STANDARD: Self = Self {
|
||||
settle: Duration::from_millis(500),
|
||||
probe: Duration::from_secs(5),
|
||||
start: Duration::from_secs(15),
|
||||
tick: Duration::from_secs(1),
|
||||
};
|
||||
}
|
||||
|
||||
/// What a run hands its caller while it runs.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum RunProgress {
|
||||
Context(RunContext),
|
||||
Output(Vec<u8>),
|
||||
/// Nothing happened for a tick.
|
||||
Idle,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Phase {
|
||||
/// Waiting for a terminal to take it up.
|
||||
Queued,
|
||||
/// ^C sent.
|
||||
Installed,
|
||||
Probing,
|
||||
Typed,
|
||||
Begun,
|
||||
Done,
|
||||
Abandoned,
|
||||
}
|
||||
|
||||
impl Phase {
|
||||
fn is_active(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Phase::Installed | Phase::Probing | Phase::Typed | Phase::Begun
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct RunState {
|
||||
nonce: String,
|
||||
command: String,
|
||||
phase: Phase,
|
||||
shell: Option<RunShell>,
|
||||
scanner: RunScanner,
|
||||
/// The terminal's input, once one took the run up.
|
||||
input: Option<mpsc::Sender<TerminalTransportCommand>>,
|
||||
events: mpsc::Sender<RunEvent>,
|
||||
}
|
||||
|
||||
impl RunState {
|
||||
fn type_bytes(&self, bytes: impl Into<Vec<u8>>) {
|
||||
if let Some(input) = &self.input {
|
||||
let _ = input.send(TerminalTransportCommand::Write(bytes.into()));
|
||||
}
|
||||
}
|
||||
|
||||
fn tell(&self, event: RunEvent) {
|
||||
let _ = self.events.send(event);
|
||||
}
|
||||
|
||||
fn fail(&mut self, failure: RunFailure) {
|
||||
self.phase = Phase::Abandoned;
|
||||
self.tell(RunEvent::Failed(failure));
|
||||
}
|
||||
|
||||
/// The shell answered the probe: type the command, if it is one the
|
||||
/// line can be typed into and the line fits.
|
||||
fn probed(&mut self, exe: &str) {
|
||||
let Some(shell) = RunShell::of(exe) else {
|
||||
self.fail(RunFailure::UnsupportedShell(program_name(exe).to_owned()));
|
||||
return;
|
||||
};
|
||||
let mut line = command_line(&self.nonce, &self.command);
|
||||
line.push('\r');
|
||||
if line.len() > shell.line_limit() {
|
||||
self.fail(RunFailure::TooLong {
|
||||
limit: shell.line_limit(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
self.type_bytes(line);
|
||||
self.shell = Some(shell);
|
||||
self.phase = Phase::Typed;
|
||||
self.tell(RunEvent::Typed);
|
||||
}
|
||||
}
|
||||
|
||||
/// One `exec --terminal` run, as the request it serves holds it. Dropping
|
||||
/// it gives the run up, with a ^C when its command was typed.
|
||||
pub struct TerminalRun {
|
||||
state: Arc<Mutex<RunState>>,
|
||||
}
|
||||
|
||||
/// The same run, as the terminal it runs in holds it.
|
||||
#[derive(Clone)]
|
||||
pub struct RunHandle {
|
||||
state: Arc<Mutex<RunState>>,
|
||||
}
|
||||
|
||||
fn lock(state: &Mutex<RunState>) -> MutexGuard<'_, RunState> {
|
||||
state.lock().unwrap_or_else(|error| error.into_inner())
|
||||
}
|
||||
|
||||
impl TerminalRun {
|
||||
pub fn new(command: String) -> (Self, mpsc::Receiver<RunEvent>) {
|
||||
let nonce: String = rand::rng()
|
||||
.sample_iter(Alphanumeric)
|
||||
.take(NONCE_LENGTH)
|
||||
.map(char::from)
|
||||
.collect();
|
||||
let (events, receiver) = mpsc::channel();
|
||||
let state = RunState {
|
||||
scanner: RunScanner::new(&nonce),
|
||||
nonce,
|
||||
command,
|
||||
phase: Phase::Queued,
|
||||
shell: None,
|
||||
input: None,
|
||||
events,
|
||||
};
|
||||
(
|
||||
Self {
|
||||
state: Arc::new(Mutex::new(state)),
|
||||
},
|
||||
receiver,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn handle(&self) -> RunHandle {
|
||||
RunHandle {
|
||||
state: self.state.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Type the probe, once the terminal has sent its ^C.
|
||||
pub fn type_probe(&self) {
|
||||
let mut state = lock(&self.state);
|
||||
if state.phase == Phase::Installed {
|
||||
let mut line = probe_line(&state.nonce);
|
||||
line.push('\r');
|
||||
state.type_bytes(line);
|
||||
state.phase = Phase::Probing;
|
||||
}
|
||||
}
|
||||
|
||||
/// Give the run up for `reason`, unless it got past that meanwhile:
|
||||
/// whether it was given up. A command typed and not started gets a ^C,
|
||||
/// which also withdraws one a bastion host holds for review.
|
||||
pub fn give_up(&self, reason: BusyReason) -> bool {
|
||||
let mut state = lock(&self.state);
|
||||
let given_up = match reason {
|
||||
BusyReason::NotStarted => state.phase == Phase::Typed,
|
||||
_ => matches!(
|
||||
state.phase,
|
||||
Phase::Queued | Phase::Installed | Phase::Probing
|
||||
),
|
||||
};
|
||||
if given_up {
|
||||
if state.phase == Phase::Typed {
|
||||
state.type_bytes(b"\x03".to_vec());
|
||||
}
|
||||
state.phase = Phase::Abandoned;
|
||||
}
|
||||
given_up
|
||||
}
|
||||
|
||||
/// Give the run up whatever it does: ^C to a command typed and not
|
||||
/// finished.
|
||||
pub fn cancel(&self) {
|
||||
let mut state = lock(&self.state);
|
||||
match state.phase {
|
||||
Phase::Done | Phase::Abandoned => return,
|
||||
Phase::Typed | Phase::Begun => state.type_bytes(b"\x03".to_vec()),
|
||||
Phase::Queued | Phase::Installed | Phase::Probing => {}
|
||||
}
|
||||
state.phase = Phase::Abandoned;
|
||||
}
|
||||
|
||||
/// See the run through once a terminal took it up (after
|
||||
/// [`RunEvent::Accepted`]): probe, wait for the command, and hand what
|
||||
/// it prints to `progress`, with [`RunProgress::Idle`] every quiet
|
||||
/// tick. The exit code, or why there is none. When `progress` fails,
|
||||
/// its reader is gone: the run is cancelled, and given up as
|
||||
/// [`RunFailure::Disconnected`], which nobody hears.
|
||||
pub fn drive(
|
||||
&self,
|
||||
events: &mpsc::Receiver<RunEvent>,
|
||||
timing: RunTiming,
|
||||
progress: &mut dyn FnMut(RunProgress) -> io::Result<()>,
|
||||
) -> Result<i32, RunFailure> {
|
||||
std::thread::sleep(timing.settle);
|
||||
self.type_probe();
|
||||
let mut deadline = Some((Instant::now() + timing.probe, BusyReason::NoAnswer));
|
||||
loop {
|
||||
let next = match events.recv_timeout(timing.tick) {
|
||||
Ok(RunEvent::Accepted) => continue,
|
||||
Ok(RunEvent::Typed) => {
|
||||
deadline = Some((Instant::now() + timing.start, BusyReason::NotStarted));
|
||||
continue;
|
||||
}
|
||||
Ok(RunEvent::Begun(context)) => {
|
||||
deadline = None;
|
||||
RunProgress::Context(context)
|
||||
}
|
||||
Ok(RunEvent::Output(bytes)) => RunProgress::Output(bytes),
|
||||
Ok(RunEvent::Exited(code)) => return Ok(code),
|
||||
Ok(RunEvent::Failed(failure)) => return Err(failure),
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||
if let Some((at, reason)) = deadline
|
||||
&& Instant::now() >= at
|
||||
{
|
||||
if self.give_up(reason) {
|
||||
return Err(RunFailure::Busy(reason));
|
||||
}
|
||||
// It got further just now: its event is on the way.
|
||||
deadline = None;
|
||||
}
|
||||
RunProgress::Idle
|
||||
}
|
||||
// The run itself holds the sender.
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => return Err(RunFailure::Disconnected),
|
||||
};
|
||||
if progress(next).is_err() {
|
||||
self.cancel();
|
||||
return Err(RunFailure::Disconnected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TerminalRun {
|
||||
fn drop(&mut self) {
|
||||
self.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
impl RunHandle {
|
||||
/// Take the run up in a terminal whose input is `input`; the ^C is the
|
||||
/// terminal's to send. False when the request gave it up already.
|
||||
pub fn accept(&self, input: mpsc::Sender<TerminalTransportCommand>) -> bool {
|
||||
let mut state = lock(&self.state);
|
||||
if state.phase != Phase::Queued {
|
||||
return false;
|
||||
}
|
||||
state.input = Some(input);
|
||||
state.phase = Phase::Installed;
|
||||
state.tell(RunEvent::Accepted);
|
||||
true
|
||||
}
|
||||
|
||||
/// Say why no terminal can take the run up.
|
||||
pub fn fail(&self, failure: RunFailure) {
|
||||
let mut state = lock(&self.state);
|
||||
if state.phase == Phase::Queued {
|
||||
state.fail(failure);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the run still has its terminal: one given up does not,
|
||||
/// whatever its command still does.
|
||||
pub(super) fn is_active(&self) -> bool {
|
||||
lock(&self.state).phase.is_active()
|
||||
}
|
||||
|
||||
/// Read the terminal's output for the run's markers and output;
|
||||
/// whether the run goes on.
|
||||
pub fn feed(&self, bytes: &[u8]) -> bool {
|
||||
let mut state = lock(&self.state);
|
||||
if !state.phase.is_active() {
|
||||
return false;
|
||||
}
|
||||
for scanned in state.scanner.scan(bytes) {
|
||||
match (scanned, state.phase) {
|
||||
(Scanned::Probe(exe), Phase::Probing) => state.probed(&exe),
|
||||
(Scanned::Begin(hex), Phase::Typed) => {
|
||||
let shell = state.shell.map(RunShell::name).unwrap_or_default();
|
||||
state.phase = Phase::Begun;
|
||||
state.tell(RunEvent::Begun(RunContext::read(&hex, shell)));
|
||||
}
|
||||
(Scanned::Output(bytes), Phase::Begun) => state.tell(RunEvent::Output(bytes)),
|
||||
(Scanned::End(code), Phase::Begun) => {
|
||||
state.phase = Phase::Done;
|
||||
state.tell(RunEvent::Exited(code));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
state.phase.is_active()
|
||||
}
|
||||
|
||||
/// The terminal's connection is gone.
|
||||
pub(super) fn disconnected(&self) {
|
||||
let mut state = lock(&self.state);
|
||||
match state.phase {
|
||||
Phase::Installed | Phase::Probing => state.fail(RunFailure::NotConnected),
|
||||
Phase::Typed | Phase::Begun => state.fail(RunFailure::Disconnected),
|
||||
Phase::Queued | Phase::Done | Phase::Abandoned => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The probe line, with a leading space so that bash with `ignorespace`
|
||||
/// keeps it out of the history.
|
||||
fn probe_line(nonce: &str) -> String {
|
||||
format!(" sh -c '{PROBE_SCRIPT}' sh {nonce}")
|
||||
}
|
||||
|
||||
/// The command line: `command` as [`encode_command`] writes it.
|
||||
fn command_line(nonce: &str, command: &str) -> String {
|
||||
format!(
|
||||
" sh -c '{COMMAND_SCRIPT}' sh {nonce} '{}'",
|
||||
encode_command(command.as_bytes())
|
||||
)
|
||||
}
|
||||
|
||||
/// `command` as `printf %b` reads it back, with nothing in it a shell's
|
||||
/// line editor or single quotes would take for their own: control
|
||||
/// characters, quotes, `\`, `!` (csh-style history) and every byte above
|
||||
/// ASCII (Meta keys to readline) as `\0NNN`. The rest reads as it is, for
|
||||
/// the user and a bastion host's command audit alike.
|
||||
pub(super) fn encode_command(command: &[u8]) -> String {
|
||||
let mut encoded = String::with_capacity(command.len());
|
||||
for &byte in command {
|
||||
if !(0x20..0x7f).contains(&byte) || matches!(byte, b'\'' | b'\\' | b'!' | b'"') {
|
||||
encoded.push_str(&format!("\\0{byte:03o}"));
|
||||
} else {
|
||||
encoded.push(char::from(byte));
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
/// What the scanner found in the output, in order.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Scanned {
|
||||
/// Marker P, with the interactive shell's program.
|
||||
Probe(String),
|
||||
/// Marker B, with the context in hex.
|
||||
Begin(Vec<u8>),
|
||||
/// What the command printed, between B and E, with CRLF as LF.
|
||||
Output(Vec<u8>),
|
||||
End(i32),
|
||||
}
|
||||
|
||||
/// Finds a run's markers in terminal output, however it is split into
|
||||
/// chunks, and what the command printed between B and E.
|
||||
struct RunScanner {
|
||||
nonce: Vec<u8>,
|
||||
/// What may still turn out to be (part of) a marker.
|
||||
pending: Vec<u8>,
|
||||
capturing: bool,
|
||||
/// A CR the next chunk may make CRLF.
|
||||
carriage_return: bool,
|
||||
}
|
||||
|
||||
impl RunScanner {
|
||||
fn new(nonce: &str) -> Self {
|
||||
Self {
|
||||
nonce: nonce.as_bytes().to_vec(),
|
||||
pending: Vec::new(),
|
||||
capturing: false,
|
||||
carriage_return: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn scan(&mut self, bytes: &[u8]) -> Vec<Scanned> {
|
||||
let mut text = std::mem::take(&mut self.pending);
|
||||
text.extend_from_slice(bytes);
|
||||
let mut found = Vec::new();
|
||||
let mut output = Vec::new();
|
||||
let mut rest = text.as_slice();
|
||||
loop {
|
||||
let Some(at) = find(rest, MARKER) else {
|
||||
let kept = partial_marker(rest);
|
||||
self.capture(&rest[..rest.len() - kept], &mut output);
|
||||
self.pending = rest[rest.len() - kept..].to_vec();
|
||||
break;
|
||||
};
|
||||
self.capture(&rest[..at], &mut output);
|
||||
let body = &rest[at + MARKER.len()..];
|
||||
match terminator(body) {
|
||||
Some((end, length)) => {
|
||||
if let Some(marker) = self.marker(&body[..end], &mut output) {
|
||||
if !output.is_empty() {
|
||||
found.push(Scanned::Output(std::mem::take(&mut output)));
|
||||
}
|
||||
found.push(marker);
|
||||
}
|
||||
rest = &body[end + length..];
|
||||
}
|
||||
// Too long to be one: not a marker after all.
|
||||
None if body.len() > MARKER_LIMIT => {
|
||||
self.capture(&rest[at..at + MARKER.len()], &mut output);
|
||||
rest = body;
|
||||
}
|
||||
None => {
|
||||
self.pending = rest[at..].to_vec();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !output.is_empty() {
|
||||
found.push(Scanned::Output(output));
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// Keep `bytes` when they are the command's, with CRLF as LF.
|
||||
fn capture(&mut self, bytes: &[u8], output: &mut Vec<u8>) {
|
||||
if !self.capturing {
|
||||
return;
|
||||
}
|
||||
for &byte in bytes {
|
||||
if std::mem::take(&mut self.carriage_return) && byte != b'\n' {
|
||||
output.push(b'\r');
|
||||
}
|
||||
if byte == b'\r' {
|
||||
self.carriage_return = true;
|
||||
} else {
|
||||
output.push(byte);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The marker `body` (after `ESC ] 6973 ;`) is, if it is this run's.
|
||||
fn marker(&mut self, body: &[u8], output: &mut Vec<u8>) -> Option<Scanned> {
|
||||
let mut fields = body.splitn(3, |byte| *byte == b';');
|
||||
let (kind, nonce, payload) = (fields.next()?, fields.next()?, fields.next()?);
|
||||
if nonce != self.nonce.as_slice() {
|
||||
return None;
|
||||
}
|
||||
match kind {
|
||||
b"P" => Some(Scanned::Probe(
|
||||
String::from_utf8_lossy(payload).into_owned(),
|
||||
)),
|
||||
b"B" => {
|
||||
self.capturing = true;
|
||||
Some(Scanned::Begin(payload.to_vec()))
|
||||
}
|
||||
b"E" => {
|
||||
if std::mem::take(&mut self.carriage_return) {
|
||||
output.push(b'\r');
|
||||
}
|
||||
self.capturing = false;
|
||||
// Never anything but digits; the run ends either way.
|
||||
let code = std::str::from_utf8(payload)
|
||||
.ok()
|
||||
.and_then(|code| code.trim().parse().ok())
|
||||
.unwrap_or(255);
|
||||
Some(Scanned::End(code))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
haystack
|
||||
.windows(needle.len())
|
||||
.position(|window| window == needle)
|
||||
}
|
||||
|
||||
/// How many bytes at the end of `bytes` begin a marker.
|
||||
fn partial_marker(bytes: &[u8]) -> usize {
|
||||
(1..MARKER.len())
|
||||
.rev()
|
||||
.find(|&length| length <= bytes.len() && bytes.ends_with(&MARKER[..length]))
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Where an OSC body ends, and how long its terminator is: BEL or ST.
|
||||
fn terminator(body: &[u8]) -> Option<(usize, usize)> {
|
||||
body.iter()
|
||||
.enumerate()
|
||||
.find_map(|(index, byte)| match byte {
|
||||
0x07 => Some((index, 1)),
|
||||
0x1b if body.get(index + 1) == Some(&b'\\') => Some((index, 2)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "run_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,712 @@
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Timing for runs whose terminal is a thread of the test.
|
||||
const QUICK: RunTiming = RunTiming {
|
||||
settle: Duration::ZERO,
|
||||
probe: Duration::from_millis(200),
|
||||
start: Duration::from_millis(200),
|
||||
tick: Duration::from_millis(10),
|
||||
};
|
||||
|
||||
fn nonce(run: &TerminalRun) -> String {
|
||||
lock(&run.state).nonce.clone()
|
||||
}
|
||||
|
||||
fn marker(kind: &str, nonce: &str, payload: &str) -> String {
|
||||
format!("\x1b]6973;{kind};{nonce};{payload}\x07")
|
||||
}
|
||||
|
||||
fn hex(text: &str) -> String {
|
||||
text.bytes().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
/// What was typed into the terminal so far, as text.
|
||||
fn typed(input: &mpsc::Receiver<TerminalTransportCommand>) -> String {
|
||||
input
|
||||
.try_iter()
|
||||
.map(|command| match command {
|
||||
TerminalTransportCommand::Write(bytes) => String::from_utf8(bytes).unwrap(),
|
||||
_ => String::new(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A run a terminal took up, with the terminal's input.
|
||||
fn accepted(
|
||||
command: &str,
|
||||
) -> (
|
||||
TerminalRun,
|
||||
mpsc::Receiver<RunEvent>,
|
||||
mpsc::Receiver<TerminalTransportCommand>,
|
||||
) {
|
||||
let (run, events) = TerminalRun::new(command.to_owned());
|
||||
let (input, typed) = mpsc::channel();
|
||||
assert!(run.handle().accept(input));
|
||||
assert_eq!(events.try_recv(), Ok(RunEvent::Accepted));
|
||||
(run, events, typed)
|
||||
}
|
||||
|
||||
/// What the scanner found, with the output of successive chunks joined.
|
||||
fn joined(found: Vec<Scanned>) -> Vec<Scanned> {
|
||||
let mut joined: Vec<Scanned> = Vec::new();
|
||||
for scanned in found {
|
||||
match (joined.last_mut(), scanned) {
|
||||
(Some(Scanned::Output(before)), Scanned::Output(more)) => before.extend(more),
|
||||
(_, scanned) => joined.push(scanned),
|
||||
}
|
||||
}
|
||||
joined
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markers_are_found_however_the_output_is_split() {
|
||||
let mut scanner = RunScanner::new("abc");
|
||||
let begin = marker("B", "abc", "6869");
|
||||
let mut found = scanner.scan(format!("$ echo\r\n{begin}out\r\nline2\r").as_bytes());
|
||||
found.extend(scanner.scan(b"\n\x1b]69"));
|
||||
found.extend(scanner.scan(b"73;E;abc;3\x07$ "));
|
||||
assert_eq!(
|
||||
joined(found),
|
||||
vec![
|
||||
Scanned::Begin(b"6869".to_vec()),
|
||||
Scanned::Output(b"out\nline2\n".to_vec()),
|
||||
Scanned::End(3),
|
||||
]
|
||||
);
|
||||
// After E, the prompt is not the command's.
|
||||
assert!(scanner.scan(b"more\r\n").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_this_runs_markers_count_and_either_terminator_ends_them() {
|
||||
let mut scanner = RunScanner::new("abc");
|
||||
let found = scanner.scan(
|
||||
format!(
|
||||
"{}{}\x1b]6973;P;abc;/usr/bin/bash\x1b\\",
|
||||
marker("P", "other", "/bin/zsh"),
|
||||
marker("E", "abc", "oops"),
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
assert_eq!(
|
||||
found,
|
||||
vec![Scanned::End(255), Scanned::Probe("/usr/bin/bash".into())]
|
||||
);
|
||||
// The echo of a typed line has the escape as text.
|
||||
assert!(
|
||||
scanner
|
||||
.scan(br#" sh -c 'printf "\033]6973;P;%s" "$1"' sh abc"#)
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lone_carriage_return_stays_and_crlf_becomes_lf() {
|
||||
let mut scanner = RunScanner::new("n");
|
||||
let found = scanner.scan(
|
||||
format!(
|
||||
"{}50%\r100%\r\n{}",
|
||||
marker("B", "n", ""),
|
||||
marker("E", "n", "0")
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
assert_eq!(found[1], Scanned::Output(b"50%\r100%\n".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shells_are_told_by_their_program() {
|
||||
assert_eq!(RunShell::of("/usr/bin/bash"), Some(RunShell::Bash));
|
||||
// Upgraded under the running shell.
|
||||
assert_eq!(
|
||||
RunShell::of("/usr/bin/bash (deleted)"),
|
||||
Some(RunShell::Bash)
|
||||
);
|
||||
assert_eq!(RunShell::of("/usr/bin/dash"), Some(RunShell::Dash));
|
||||
assert_eq!(RunShell::of("/bin/busybox\n"), Some(RunShell::Busybox));
|
||||
assert_eq!(RunShell::of("/usr/bin/zsh"), None);
|
||||
assert_eq!(RunShell::of(""), None);
|
||||
assert_eq!(program_name("/tmp/mysh"), "mysh");
|
||||
assert_eq!(RunShell::Busybox.line_limit(), 1000);
|
||||
assert_eq!(RunShell::Dash.line_limit(), 4000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_context_keeps_a_directory_with_newlines_whole() {
|
||||
let context = RunContext::read(hex("root\nweb-01\n/srv/a\nb\nbash").as_bytes(), "dash");
|
||||
assert_eq!(
|
||||
context,
|
||||
RunContext {
|
||||
user: "root".into(),
|
||||
host: "web-01".into(),
|
||||
cwd: "/srv/a\nb".into(),
|
||||
shell: "dash".into(),
|
||||
interp: "bash".into(),
|
||||
}
|
||||
);
|
||||
// `od` missing: nothing known, the run goes on.
|
||||
assert_eq!(RunContext::read(b"", "bash").user, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_probes_then_types_its_command_and_reads_its_output() {
|
||||
let (run, events, input) = accepted("uname -a");
|
||||
let handle = run.handle();
|
||||
let nonce = nonce(&run);
|
||||
run.type_probe();
|
||||
let probe = typed(&input);
|
||||
assert!(probe.starts_with(" sh -c '") && probe.ends_with(&format!(" sh {nonce}\r")));
|
||||
|
||||
assert!(handle.feed(marker("P", &nonce, "/usr/bin/bash").as_bytes()));
|
||||
assert_eq!(events.try_recv(), Ok(RunEvent::Typed));
|
||||
let command = typed(&input);
|
||||
assert!(
|
||||
command.ends_with(&format!(" sh {nonce} 'uname -a'\r")),
|
||||
"{command}"
|
||||
);
|
||||
|
||||
let context = hex("root\nweb-01\n/root\nbash");
|
||||
let output = format!(
|
||||
"{}Linux\r\n{}$ ",
|
||||
marker("B", &nonce, &context),
|
||||
marker("E", &nonce, "0")
|
||||
);
|
||||
assert!(!handle.feed(output.as_bytes()));
|
||||
let RunEvent::Begun(context) = events.try_recv().unwrap() else {
|
||||
panic!("not begun");
|
||||
};
|
||||
assert_eq!(
|
||||
(context.user.as_str(), context.shell.as_str()),
|
||||
("root", "bash")
|
||||
);
|
||||
assert_eq!(events.try_recv(), Ok(RunEvent::Output(b"Linux\n".to_vec())));
|
||||
assert_eq!(events.try_recv(), Ok(RunEvent::Exited(0)));
|
||||
// Finished: dropping it sends nothing.
|
||||
drop(run);
|
||||
assert_eq!(typed(&input), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_answer_after_the_run_was_given_up_types_nothing() {
|
||||
let (run, events, input) = accepted("rm -rf build");
|
||||
let handle = run.handle();
|
||||
run.type_probe();
|
||||
typed(&input);
|
||||
assert!(run.give_up(BusyReason::NoAnswer));
|
||||
assert!(!handle.feed(marker("P", &nonce(&run), "/usr/bin/bash").as_bytes()));
|
||||
assert_eq!(typed(&input), "");
|
||||
assert!(events.try_recv().is_err());
|
||||
assert!(!handle.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_typed_command_gets_a_ctrl_c() {
|
||||
let (run, _events, input) = accepted("true");
|
||||
run.type_probe();
|
||||
typed(&input);
|
||||
// Probing: the probe is harmless where it waits.
|
||||
assert!(!run.give_up(BusyReason::NotStarted));
|
||||
run.cancel();
|
||||
assert_eq!(typed(&input), "");
|
||||
|
||||
let (run, _events, input) = accepted("sleep 9");
|
||||
run.type_probe();
|
||||
run.handle()
|
||||
.feed(marker("P", &nonce(&run), "/bin/busybox").as_bytes());
|
||||
typed(&input);
|
||||
drop(run);
|
||||
assert_eq!(typed(&input), "\x03");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shell_other_than_the_three_or_a_line_too_long_types_nothing() {
|
||||
let (run, events, input) = accepted("true");
|
||||
run.type_probe();
|
||||
typed(&input);
|
||||
run.handle()
|
||||
.feed(marker("P", &nonce(&run), "/usr/bin/zsh").as_bytes());
|
||||
assert_eq!(
|
||||
events.try_recv(),
|
||||
Ok(RunEvent::Failed(RunFailure::UnsupportedShell("zsh".into())))
|
||||
);
|
||||
assert_eq!(typed(&input), "");
|
||||
|
||||
let (run, events, input) = accepted(&"x".repeat(1000));
|
||||
run.type_probe();
|
||||
typed(&input);
|
||||
run.handle()
|
||||
.feed(marker("P", &nonce(&run), "/bin/busybox").as_bytes());
|
||||
assert_eq!(
|
||||
events.try_recv(),
|
||||
Ok(RunEvent::Failed(RunFailure::TooLong { limit: 1000 }))
|
||||
);
|
||||
assert_eq!(typed(&input), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_terminal_gone_says_whether_the_command_may_have_run() {
|
||||
let (run, events, _input) = accepted("true");
|
||||
run.type_probe();
|
||||
run.handle().disconnected();
|
||||
assert_eq!(
|
||||
events.try_recv(),
|
||||
Ok(RunEvent::Failed(RunFailure::NotConnected))
|
||||
);
|
||||
|
||||
let (run, events, _input) = accepted("true");
|
||||
run.type_probe();
|
||||
run.handle()
|
||||
.feed(marker("P", &nonce(&run), "/usr/bin/bash").as_bytes());
|
||||
events.try_recv().unwrap();
|
||||
run.handle().disconnected();
|
||||
assert_eq!(
|
||||
events.try_recv(),
|
||||
Ok(RunEvent::Failed(RunFailure::Disconnected))
|
||||
);
|
||||
}
|
||||
|
||||
/// How a fake shell answers the command line.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Answer {
|
||||
/// B, this output and E with 7.
|
||||
Runs(&'static str),
|
||||
/// B alone: still running.
|
||||
Starts,
|
||||
/// Nothing: held back.
|
||||
Nothing,
|
||||
}
|
||||
|
||||
/// Answers a run's lines the way a shell would, on a thread: P at once,
|
||||
/// then the command line as `answer` says. Stops once nothing is typed
|
||||
/// for a while (the run's handle keeps the input open); everything typed.
|
||||
fn fake_shell(
|
||||
run: &TerminalRun,
|
||||
input: mpsc::Receiver<TerminalTransportCommand>,
|
||||
answer: Answer,
|
||||
) -> std::thread::JoinHandle<String> {
|
||||
let (handle, nonce) = (run.handle(), nonce(run));
|
||||
std::thread::spawn(move || {
|
||||
let mut all = String::new();
|
||||
while let Ok(TerminalTransportCommand::Write(bytes)) =
|
||||
input.recv_timeout(Duration::from_millis(500))
|
||||
{
|
||||
let line = String::from_utf8(bytes).unwrap();
|
||||
all.push_str(&line);
|
||||
let reply = if line.contains("6973;P;") {
|
||||
marker("P", &nonce, "/usr/bin/bash")
|
||||
} else if line.contains("6973;B;") {
|
||||
match answer {
|
||||
Answer::Runs(output) => format!(
|
||||
"{}{output}{}",
|
||||
marker("B", &nonce, ""),
|
||||
marker("E", &nonce, "7")
|
||||
),
|
||||
Answer::Starts => marker("B", &nonce, ""),
|
||||
Answer::Nothing => continue,
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
handle.feed(reply.as_bytes());
|
||||
}
|
||||
all
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn driving_a_run_passes_its_output_on_and_ends_with_its_code() {
|
||||
let (run, events, input) = accepted("make");
|
||||
let shell = fake_shell(&run, input, Answer::Runs("built\r\n"));
|
||||
let mut progress = Vec::new();
|
||||
let code = run.drive(&events, QUICK, &mut |step| {
|
||||
if step != RunProgress::Idle {
|
||||
progress.push(step);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
assert_eq!(code, Ok(7));
|
||||
assert!(matches!(progress[0], RunProgress::Context(_)));
|
||||
assert_eq!(progress[1], RunProgress::Output(b"built\n".to_vec()));
|
||||
drop(run);
|
||||
assert!(!shell.join().unwrap().contains('\x03'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_nobody_answers_or_that_never_starts_is_busy() {
|
||||
let (run, events, _input) = accepted("true");
|
||||
assert_eq!(
|
||||
run.drive(&events, QUICK, &mut |_| Ok(())),
|
||||
Err(RunFailure::Busy(BusyReason::NoAnswer))
|
||||
);
|
||||
|
||||
// Held back by a command filter: withdrawn with ^C.
|
||||
let (run, events, input) = accepted("reboot");
|
||||
let shell = fake_shell(&run, input, Answer::Nothing);
|
||||
assert_eq!(
|
||||
run.drive(&events, QUICK, &mut |_| Ok(())),
|
||||
Err(RunFailure::Busy(BusyReason::NotStarted))
|
||||
);
|
||||
drop(run);
|
||||
assert!(shell.join().unwrap().ends_with('\x03'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_caller_gone_cancels_the_command() {
|
||||
let (run, events, input) = accepted("sleep 100");
|
||||
let shell = fake_shell(&run, input, Answer::Starts);
|
||||
let mut begun = false;
|
||||
let result = run.drive(&events, QUICK, &mut |step| match step {
|
||||
RunProgress::Context(_) => {
|
||||
begun = true;
|
||||
Ok(())
|
||||
}
|
||||
RunProgress::Idle if begun => Err(io::Error::from(io::ErrorKind::BrokenPipe)),
|
||||
_ => Ok(()),
|
||||
});
|
||||
assert_eq!(result, Err(RunFailure::Disconnected));
|
||||
drop(run);
|
||||
assert!(shell.join().unwrap().ends_with('\x03'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_encoded_command_has_nothing_a_line_editor_or_quotes_would_take() {
|
||||
let encoded = encode_command("echo 'it''s' \"$HOME\" a\\b !x\t中\n".as_bytes());
|
||||
assert!(encoded.bytes().all(|byte| (0x20..0x7f).contains(&byte)));
|
||||
assert!(!encoded.contains(['\'', '"', '!']));
|
||||
assert!(!encoded.contains("\\\\"));
|
||||
assert!(encoded.starts_with("echo \\0047it"), "{encoded}");
|
||||
assert!(encoded.contains("$HOME"));
|
||||
}
|
||||
|
||||
/// The scripts run for real by this machine's `sh`.
|
||||
#[cfg(unix)]
|
||||
mod sh {
|
||||
use super::*;
|
||||
use crate::testing::{sh, sh_accepts};
|
||||
|
||||
#[test]
|
||||
fn the_fixed_scripts_are_plain_sh() {
|
||||
assert!(sh_accepts(PROBE_SCRIPT));
|
||||
assert!(sh_accepts(COMMAND_SCRIPT));
|
||||
for script in [PROBE_SCRIPT, COMMAND_SCRIPT] {
|
||||
assert!(!script.contains(['\'', '!']) && !script.contains("\\\\"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printf_b_reads_every_byte_back() {
|
||||
let bytes: Vec<u8> = (0..=255).collect();
|
||||
let output = sh(
|
||||
&format!("printf %b '{}' | od -An -tx1", encode_command(&bytes)),
|
||||
None,
|
||||
);
|
||||
let dumped: String = String::from_utf8(output.stdout)
|
||||
.unwrap()
|
||||
.split_whitespace()
|
||||
.collect();
|
||||
let expected: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
|
||||
assert_eq!(dumped, expected);
|
||||
}
|
||||
|
||||
/// The command line as the interactive shell would run it, its output
|
||||
/// read back by the scanner.
|
||||
fn run_line(command: &str) -> (Vec<Scanned>, String) {
|
||||
let nonce = "Nonce0123456";
|
||||
let line = command_line(nonce, command);
|
||||
let output = sh(line.trim_start(), None);
|
||||
let mut scanner = RunScanner::new(nonce);
|
||||
(scanner.scan(&output.stdout), line)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_command_line_runs_any_command_and_reports_its_status() {
|
||||
let (found, _) =
|
||||
run_line("printf '%s\\n' \"it's\" 'a\\b' '$HOME' '!x' 中文; echo err >&2; exit 3");
|
||||
let [
|
||||
Scanned::Begin(context),
|
||||
Scanned::Output(output),
|
||||
Scanned::End(code),
|
||||
] = found.as_slice()
|
||||
else {
|
||||
panic!("{found:?}");
|
||||
};
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(output),
|
||||
"it's\na\\b\n$HOME\n!x\n中文\nerr\n"
|
||||
);
|
||||
assert_eq!(*code, 3);
|
||||
let context = RunContext::read(context, "bash");
|
||||
assert!(!context.user.is_empty() && context.cwd.starts_with('/'));
|
||||
assert!(["bash", "sh"].contains(&context.interp.as_str()));
|
||||
|
||||
// Several lines, and a command killed by a signal.
|
||||
let (found, _) = run_line("cd /\npwd\nkill -TERM $$");
|
||||
// dash, as the sh that waits for it, also says "Terminated" there.
|
||||
let Scanned::Output(output) = &found[1] else {
|
||||
panic!("{found:?}");
|
||||
};
|
||||
assert!(output.starts_with(b"/\n"), "{found:?}");
|
||||
assert_eq!(found[2], Scanned::End(143));
|
||||
let (found, _) = run_line("kill -INT $$");
|
||||
assert_eq!(found.last(), Some(&Scanned::End(130)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The run against real shells in containers, on a real terminal: what
|
||||
/// the tests above cannot show. Needs Docker and the images; run with
|
||||
/// `cargo test --lib terminal::run -- --ignored` (CI: `shells.yml`).
|
||||
#[cfg(unix)]
|
||||
mod shells {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use super::*;
|
||||
use crate::testing::{Pty, pty};
|
||||
|
||||
/// A shell in a container, waited for until it shows its prompt.
|
||||
fn start(image: &str, shell: &[&str]) -> Pty {
|
||||
let mut argv = vec!["docker", "run", "--rm", "-i", "-t", image];
|
||||
argv.extend_from_slice(shell);
|
||||
let pty = pty(&argv);
|
||||
pty.output
|
||||
.recv_timeout(Duration::from_secs(60))
|
||||
.expect("the shell starts");
|
||||
settle(&pty);
|
||||
pty
|
||||
}
|
||||
|
||||
/// Wait until the terminal has been quiet a while.
|
||||
fn settle(pty: &Pty) {
|
||||
while pty.output.recv_timeout(Duration::from_millis(500)).is_ok() {}
|
||||
}
|
||||
|
||||
struct Outcome {
|
||||
result: Result<i32, RunFailure>,
|
||||
output: String,
|
||||
context: Option<RunContext>,
|
||||
}
|
||||
|
||||
/// Run `command` the way the app does: ^C, then the run. `interrupt`
|
||||
/// presses ^C that long after the command started, as the user would.
|
||||
fn run(pty: Pty, command: &str, interrupt: Option<Duration>) -> (Pty, Outcome) {
|
||||
let (run, events) = TerminalRun::new(command.to_owned());
|
||||
let handle = run.handle();
|
||||
let (input, typed) = mpsc::channel();
|
||||
assert!(handle.accept(input.clone()));
|
||||
input
|
||||
.send(TerminalTransportCommand::Write(b"\x03".to_vec()))
|
||||
.unwrap();
|
||||
let done = Arc::new(AtomicBool::new(false));
|
||||
let pump = {
|
||||
let done = done.clone();
|
||||
let handle = handle.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut pty = pty;
|
||||
let mut begun_at = None;
|
||||
while !done.load(Ordering::Acquire) {
|
||||
while let Ok(TerminalTransportCommand::Write(bytes)) = typed.try_recv() {
|
||||
pty.write(&bytes);
|
||||
}
|
||||
if let Ok(bytes) = pty.output.recv_timeout(Duration::from_millis(10)) {
|
||||
let was_begun = lock(&handle.state).phase == Phase::Begun;
|
||||
handle.feed(&bytes);
|
||||
if !was_begun && lock(&handle.state).phase == Phase::Begun {
|
||||
begun_at = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
if let (Some(after), Some(at)) = (interrupt, begun_at)
|
||||
&& at.elapsed() >= after
|
||||
{
|
||||
pty.write(b"\x03");
|
||||
begun_at = None;
|
||||
}
|
||||
}
|
||||
pty
|
||||
})
|
||||
};
|
||||
let (mut output, mut context) = (Vec::new(), None);
|
||||
let timing = RunTiming {
|
||||
probe: Duration::from_secs(10),
|
||||
tick: Duration::from_millis(100),
|
||||
..RunTiming::STANDARD
|
||||
};
|
||||
let result = run.drive(&events, timing, &mut |step| {
|
||||
match step {
|
||||
RunProgress::Context(found) => context = Some(found),
|
||||
RunProgress::Output(bytes) => output.extend(bytes),
|
||||
RunProgress::Idle => {}
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
drop(run);
|
||||
done.store(true, Ordering::Release);
|
||||
let pty = pump.join().unwrap();
|
||||
settle(&pty);
|
||||
let output = String::from_utf8(output).unwrap();
|
||||
(
|
||||
pty,
|
||||
Outcome {
|
||||
result,
|
||||
output,
|
||||
context,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn dump(text: &str) -> String {
|
||||
format!("od -An -tx1 <<'SHELLRS_END'\n{text}\nSHELLRS_END")
|
||||
}
|
||||
|
||||
/// Every ASCII character but NUL (which no shell string holds),
|
||||
/// characters whose UTF-8 has every kind of lead and continuation
|
||||
/// byte, and random ones, through a heredoc into `od`: in as many runs
|
||||
/// as the shell's line takes.
|
||||
fn round_trip(mut pty: Pty, limit: usize) -> Pty {
|
||||
let mut text: Vec<char> = (1..=127_u8).map(char::from).collect();
|
||||
text.extend("\u{80}\u{7ff}\u{800}中文\u{ffff}\u{10000}😀\u{10ffff}".chars());
|
||||
let mut random = rand::rng();
|
||||
text.extend(
|
||||
std::iter::repeat_with(|| random.random::<char>())
|
||||
.filter(|character| *character != '\0')
|
||||
.take(100),
|
||||
);
|
||||
let fits = |piece: &str| command_line("Nonce0123456", &dump(piece)).len() < limit;
|
||||
let mut rest = text.as_slice();
|
||||
while !rest.is_empty() {
|
||||
let length = (1..=rest.len())
|
||||
.take_while(|&length| fits(&rest[..length].iter().collect::<String>()))
|
||||
.last()
|
||||
.expect("one character fits");
|
||||
let piece: String = rest[..length].iter().collect();
|
||||
rest = &rest[length..];
|
||||
let (next, outcome) = run(pty, &dump(&piece), None);
|
||||
pty = next;
|
||||
assert_eq!(outcome.result, Ok(0), "{piece:?}: {}", outcome.output);
|
||||
let dumped: String = outcome.output.split_whitespace().collect();
|
||||
assert_eq!(dumped, hex(&format!("{piece}\n")), "{piece:?}");
|
||||
}
|
||||
pty
|
||||
}
|
||||
|
||||
/// The longest command that fits `limit`, and one byte more.
|
||||
fn at_limit(pty: Pty, limit: usize) -> Pty {
|
||||
let fill = |length: usize| format!("echo {}", "x".repeat(length));
|
||||
let base = command_line("Nonce0123456", &fill(0)).len() + 1;
|
||||
let (pty, outcome) = run(pty, &fill(limit - base), None);
|
||||
assert_eq!(outcome.result, Ok(0));
|
||||
assert_eq!(outcome.output.trim_end().len(), limit - base);
|
||||
let (pty, outcome) = run(pty, &fill(limit - base + 1), None);
|
||||
assert_eq!(outcome.result, Err(RunFailure::TooLong { limit }));
|
||||
pty
|
||||
}
|
||||
|
||||
/// Whatever has the terminal, ^C hands it back.
|
||||
fn takes_over(mut pty: Pty) -> Pty {
|
||||
for busy in [
|
||||
"sleep 100\r",
|
||||
"cat\r",
|
||||
"read -p 'Password: ' secret\r",
|
||||
"echo partial",
|
||||
] {
|
||||
pty.write(busy.as_bytes());
|
||||
settle(&pty);
|
||||
let (next, outcome) = run(pty, "echo ok", None);
|
||||
pty = next;
|
||||
assert_eq!(outcome.result, Ok(0), "{busy:?}");
|
||||
assert_eq!(outcome.output, "ok\n", "{busy:?}");
|
||||
}
|
||||
pty
|
||||
}
|
||||
|
||||
fn interrupted(pty: Pty) -> Pty {
|
||||
let (pty, outcome) = run(pty, "sleep 30", Some(Duration::from_secs(2)));
|
||||
assert_eq!(outcome.result, Ok(130));
|
||||
pty
|
||||
}
|
||||
|
||||
fn check(image: &str, shell: &[&str], expected: &str, interp: &str) {
|
||||
let (pty, outcome) = run(start(image, shell), "id -un", None);
|
||||
assert_eq!(outcome.result, Ok(0), "{image}: {}", outcome.output);
|
||||
let context = outcome.context.expect("context");
|
||||
assert_eq!(context.shell, expected, "{image}");
|
||||
assert_eq!(context.interp, interp, "{image}");
|
||||
assert_eq!((context.user.as_str(), context.cwd.as_str()), ("root", "/"));
|
||||
assert_eq!(outcome.output, "root\n");
|
||||
let limit = RunShell::of(expected).unwrap().line_limit();
|
||||
let pty = round_trip(pty, limit);
|
||||
let pty = at_limit(pty, limit);
|
||||
let pty = takes_over(pty);
|
||||
interrupted(pty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs Docker"]
|
||||
fn bash_4_2() {
|
||||
check("bash:4.2", &["bash"], "bash", "bash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs Docker"]
|
||||
fn bash_4_4() {
|
||||
check("bash:4.4", &["bash"], "bash", "bash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs Docker"]
|
||||
fn bash_5_0() {
|
||||
check("bash:5.0", &["bash"], "bash", "bash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs Docker"]
|
||||
fn bash_5_1() {
|
||||
check("bash:5.1", &["bash"], "bash", "bash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs Docker"]
|
||||
fn bash_5_2() {
|
||||
check("bash:5.2", &["bash"], "bash", "bash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs Docker"]
|
||||
fn bash_started_as_sh() {
|
||||
check("rockylinux:9", &["sh"], "bash", "bash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs Docker"]
|
||||
fn dash() {
|
||||
check("debian:stable-slim", &["sh"], "dash", "bash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs Docker"]
|
||||
fn busybox_ash() {
|
||||
check("alpine:3", &["sh"], "busybox", "sh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "needs Docker"]
|
||||
fn another_shell_is_refused() {
|
||||
// A shell program by another name: bash, which runs whatever its
|
||||
// file is called.
|
||||
let shell = start(
|
||||
"bash:5.2",
|
||||
&[
|
||||
"sh",
|
||||
"-c",
|
||||
"cp /usr/local/bin/bash /tmp/mysh && exec /tmp/mysh",
|
||||
],
|
||||
);
|
||||
let (_, outcome) = run(shell, "true", None);
|
||||
assert_eq!(
|
||||
outcome.result,
|
||||
Err(RunFailure::UnsupportedShell("mysh".into()))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
//! The terminal's scrollbar: gpui-kit's, over the scrollback.
|
||||
//!
|
||||
//! The grid is drawn afresh each frame and scrolls by whole lines, so there
|
||||
//! is no scroll handle for gpui-kit's `Scrollbar` to follow. This one gives
|
||||
//! it the history in pixels: a line is a row's height, the content is the
|
||||
//! scrollbar's own height plus the history, and the offset is how far the
|
||||
//! view is from the oldest line.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui_kit::component::scroll::ScrollbarHandle;
|
||||
use gpui_kit::{Bounds, Pixels, Point, Size, point, px, size};
|
||||
|
||||
use super::TerminalSnapshot;
|
||||
use super::engine::Scrollback;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct ScrollbackHandle {
|
||||
scrollback: Scrollback,
|
||||
history_size: usize,
|
||||
display_offset: usize,
|
||||
line_height: Pixels,
|
||||
/// Where the scrollbar lies, which the canvas under it notes before the
|
||||
/// scrollbar is laid out in the same frame.
|
||||
viewport: Rc<Cell<Bounds<Pixels>>>,
|
||||
}
|
||||
|
||||
impl ScrollbackHandle {
|
||||
pub(super) fn new(
|
||||
scrollback: Scrollback,
|
||||
snapshot: &TerminalSnapshot,
|
||||
line_height: Pixels,
|
||||
) -> Self {
|
||||
Self {
|
||||
scrollback,
|
||||
history_size: snapshot.history_size,
|
||||
display_offset: snapshot.display_offset,
|
||||
line_height,
|
||||
viewport: Rc::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_viewport(&self, bounds: Bounds<Pixels>) {
|
||||
self.viewport.set(bounds);
|
||||
}
|
||||
}
|
||||
|
||||
impl ScrollbarHandle for ScrollbackHandle {
|
||||
fn viewport_bounds(&self) -> Bounds<Pixels> {
|
||||
self.viewport.get()
|
||||
}
|
||||
|
||||
fn offset(&self) -> Point<Pixels> {
|
||||
point(
|
||||
px(0.),
|
||||
offset(self.history_size, self.display_offset, self.line_height),
|
||||
)
|
||||
}
|
||||
|
||||
fn set_offset(&self, offset: Point<Pixels>) {
|
||||
self.scrollback
|
||||
.scroll_to(lines_from_top(offset.y, self.line_height));
|
||||
}
|
||||
|
||||
fn content_size(&self) -> Size<Pixels> {
|
||||
let viewport = self.viewport.get().size;
|
||||
size(
|
||||
viewport.width,
|
||||
viewport.height + self.line_height * self.history_size as f32,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// How far the view is scrolled down from the oldest line, as gpui-kit
|
||||
/// counts it: zero at the top, negative below.
|
||||
fn offset(history_size: usize, display_offset: usize, line_height: Pixels) -> Pixels {
|
||||
-(line_height * history_size.saturating_sub(display_offset) as f32)
|
||||
}
|
||||
|
||||
/// The line at the top of the view for a scroll `offset`.
|
||||
fn lines_from_top(offset: Pixels, line_height: Pixels) -> usize {
|
||||
(-offset / line_height).round().max(0.) as usize
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_offset_counts_whole_lines_from_the_oldest() {
|
||||
let line = px(17.);
|
||||
// At the bottom of 100 lines of history, 100 lines down.
|
||||
assert_eq!(offset(100, 0, line), px(-1700.));
|
||||
// At the top.
|
||||
assert_eq!(offset(100, 100, line), px(0.));
|
||||
assert_eq!(lines_from_top(offset(100, 40, line), line), 60);
|
||||
// A drag lands between lines; past the top is the top.
|
||||
assert_eq!(lines_from_top(px(-25.), line), 1);
|
||||
assert_eq!(lines_from_top(px(-26.), line), 2);
|
||||
assert_eq!(lines_from_top(px(5.), line), 0);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use gpui_kit::component::{
|
||||
h_flex,
|
||||
input::{Input, InputEvent, InputState},
|
||||
menu::PopupMenu,
|
||||
scroll::Scrollbar,
|
||||
tooltip::Tooltip,
|
||||
v_flex,
|
||||
};
|
||||
@@ -29,10 +30,12 @@ use crate::connection::{ConnectionPromptReply, Latency};
|
||||
use crate::i18n::{UiLocale, t};
|
||||
|
||||
use super::mouse::{self, MouseReport, ReportButton, ReportKind};
|
||||
use super::scrollbar::ScrollbackHandle;
|
||||
use super::search::SearchMark;
|
||||
use super::{
|
||||
SearchDirection, SharedTerminalTransportFactory, TerminalColors, TerminalEngine, TerminalEvent,
|
||||
TerminalFont, TerminalLifecycle, TerminalSize, TerminalSnapshot, TerminalStatus, TerminalTheme,
|
||||
RightClick, SearchDirection, SharedTerminalTransportFactory, TerminalColors, TerminalEngine,
|
||||
TerminalEvent, TerminalFont, TerminalInteraction, TerminalLifecycle, TerminalSize,
|
||||
TerminalSnapshot, TerminalStatus, TerminalTheme,
|
||||
};
|
||||
|
||||
pub const TERMINAL_KEY_CONTEXT: &str = "Terminal";
|
||||
@@ -402,6 +405,11 @@ impl TerminalView {
|
||||
self.engine.read(cx).exec(command)
|
||||
}
|
||||
|
||||
/// Take up an `exec --terminal` run; see [`TerminalEngine::run_command`].
|
||||
pub fn run_command(&self, run: super::RunHandle, cx: &App) -> Result<(), super::RunFailure> {
|
||||
self.engine.read(cx).run_command(run)
|
||||
}
|
||||
|
||||
pub fn screen_text(&self, cx: &App) -> String {
|
||||
self.engine.read(cx).snapshot().visible_text()
|
||||
}
|
||||
@@ -634,6 +642,15 @@ impl TerminalView {
|
||||
.update(cx, |engine, cx| engine.update_selection(point, side, cx));
|
||||
}
|
||||
|
||||
/// The mouse let go of a selection. With 选中复制 on, what it took in
|
||||
/// goes to the clipboard; a click that selected nothing leaves it be.
|
||||
fn finish_selection(&mut self, cx: &mut Context<Self>) {
|
||||
self.selecting = false;
|
||||
if TerminalInteraction::current(cx).copy_on_select {
|
||||
self.copy_selection(cx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell the program of the mouse, if it asked to hear of it. Shift keeps
|
||||
/// the mouse for selecting text, unless a reported button is down.
|
||||
/// Whether the program was told.
|
||||
@@ -729,6 +746,18 @@ impl TerminalView {
|
||||
self.engine.read(cx).prepare_for_user_input();
|
||||
}
|
||||
|
||||
/// Right-clicking does what 右键行为 says.
|
||||
fn right_click(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
|
||||
match TerminalInteraction::current(cx).right_click {
|
||||
RightClick::Nothing => {}
|
||||
RightClick::Paste => {
|
||||
self.focus(window, cx);
|
||||
self.paste_clipboard(cx);
|
||||
}
|
||||
RightClick::Menu => self.open_context_menu(event, window, cx),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_context_menu(
|
||||
&mut self,
|
||||
event: &MouseDownEvent,
|
||||
@@ -910,6 +939,12 @@ impl EntityInputHandler for TerminalView {
|
||||
impl Render for TerminalView {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let snapshot = self.engine.read(cx).snapshot();
|
||||
let font = TerminalFont::current(cx);
|
||||
let scrollback = ScrollbackHandle::new(
|
||||
self.engine.read(cx).scrollback(),
|
||||
&snapshot,
|
||||
font.row_height(window),
|
||||
);
|
||||
let view = cx.entity();
|
||||
let context_menu = self.context_menu.clone();
|
||||
let element = TerminalElement {
|
||||
@@ -922,7 +957,6 @@ impl Render for TerminalView {
|
||||
requested_size: self.requested_size,
|
||||
};
|
||||
|
||||
let font = TerminalFont::current(cx);
|
||||
let terminal = div()
|
||||
.id(self.element_id.clone())
|
||||
.test_support()
|
||||
@@ -939,7 +973,7 @@ impl Render for TerminalView {
|
||||
this.send_user_input(b"\x1b[Z".to_vec(), cx);
|
||||
}))
|
||||
.on_key_down(cx.listener(Self::on_key_down))
|
||||
.on_mouse_down(MouseButton::Right, cx.listener(Self::open_context_menu))
|
||||
.on_mouse_down(MouseButton::Right, cx.listener(Self::right_click))
|
||||
.child(element)
|
||||
.when_some(context_menu, |terminal, (menu, position)| {
|
||||
terminal.child(
|
||||
@@ -979,6 +1013,26 @@ impl Render for TerminalView {
|
||||
// the terminal's too.
|
||||
.bg(TerminalColors::current(cx).background())
|
||||
.child(terminal)
|
||||
// Over the whole margin, so the thumb lies mostly beside the text.
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.inset_0()
|
||||
.child(
|
||||
canvas(
|
||||
{
|
||||
let scrollback = scrollback.clone();
|
||||
move |bounds, _, _| scrollback.set_viewport(bounds)
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
)
|
||||
.child(Scrollbar::vertical(&scrollback).id("terminal-scrollbar")),
|
||||
)
|
||||
.when_some(self.find.as_ref(), |container, find| {
|
||||
container.child(self.render_find_bar(find, cx))
|
||||
})
|
||||
@@ -1559,7 +1613,8 @@ impl Element for TerminalElement {
|
||||
let (point, side) = viewport.grid_point(event.position);
|
||||
view.update(cx, |view, cx| view.update_selection(point, side, cx));
|
||||
} else {
|
||||
view.update(cx, |view, _| view.selecting = false);
|
||||
// Let go where the window did not hear it.
|
||||
view.update(cx, |view, cx| view.finish_selection(cx));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1571,7 +1626,7 @@ impl Element for TerminalElement {
|
||||
let (point, side) = viewport.grid_point(event.position);
|
||||
view.update(cx, |view, cx| {
|
||||
view.update_selection(point, side, cx);
|
||||
view.selecting = false;
|
||||
view.finish_selection(cx);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -34,6 +34,70 @@ pub fn sh(script: &str, path: Option<&str>) -> std::process::Output {
|
||||
command.output().expect("sh runs")
|
||||
}
|
||||
|
||||
/// A program on a terminal of its own: what it prints comes in on `output`.
|
||||
/// Unix only, like `sh`.
|
||||
#[cfg(unix)]
|
||||
pub struct Pty {
|
||||
pub output: std::sync::mpsc::Receiver<Vec<u8>>,
|
||||
writer: Box<dyn std::io::Write + Send>,
|
||||
child: Box<dyn portable_pty::Child + Send + Sync>,
|
||||
_master: Box<dyn portable_pty::MasterPty + Send>,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Pty {
|
||||
pub fn write(&mut self, bytes: &[u8]) {
|
||||
self.writer.write_all(bytes).expect("pty takes input");
|
||||
self.writer.flush().expect("pty takes input");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl Drop for Pty {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
/// Start `argv` on a new terminal of 120 × 40.
|
||||
#[cfg(unix)]
|
||||
pub fn pty(argv: &[&str]) -> Pty {
|
||||
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
|
||||
|
||||
let pair = native_pty_system()
|
||||
.openpty(PtySize {
|
||||
rows: 40,
|
||||
cols: 120,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.expect("pty opens");
|
||||
let mut command = CommandBuilder::from_argv(argv.iter().map(Into::into).collect());
|
||||
command.env("TERM", "xterm-256color");
|
||||
let child = {
|
||||
let _forks = no_forks();
|
||||
pair.slave.spawn_command(command).expect("program starts")
|
||||
};
|
||||
drop(pair.slave);
|
||||
let mut reader = pair.master.try_clone_reader().expect("pty reads");
|
||||
let writer = pair.master.take_writer().expect("pty writes");
|
||||
let (sender, output) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let mut buffer = [0_u8; 8192];
|
||||
while let Ok(count) = std::io::Read::read(&mut reader, &mut buffer) {
|
||||
if count == 0 || sender.send(buffer[..count].to_vec()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
Pty {
|
||||
output,
|
||||
writer,
|
||||
child,
|
||||
_master: pair.master,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this machine's `sh` reads `script` without a syntax error.
|
||||
pub fn sh_accepts(script: &str) -> bool {
|
||||
let mut command = Command::new("sh");
|
||||
|
||||
+12
-3
@@ -207,8 +207,9 @@ pub trait Installer: Send + Sync {
|
||||
|
||||
/// 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);
|
||||
/// previous one's backup behind. An update newer than `current` stays:
|
||||
/// it may have been staged since start and be waiting for the restart.
|
||||
fn clean_up(&self, current: &Version);
|
||||
}
|
||||
|
||||
/// For copies that cannot replace themselves.
|
||||
@@ -237,7 +238,7 @@ impl Installer for NoInstaller {
|
||||
))
|
||||
}
|
||||
|
||||
fn clean_up(&self) {}
|
||||
fn clean_up(&self, _: &Version) {}
|
||||
}
|
||||
|
||||
/// The installer for this copy of ShellRS. `bundle` is `cx.app_path()`.
|
||||
@@ -296,6 +297,14 @@ pub(crate) fn sibling(path: &Path, name: &str) -> PathBuf {
|
||||
path.with_file_name(name)
|
||||
}
|
||||
|
||||
/// Whether `name` is an update staged as `<prefix><version>` that this copy
|
||||
/// no longer needs: one not newer than `current`, or not named by a version.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux", test))]
|
||||
pub(crate) fn is_stale_update(name: &str, prefix: &str, current: &Version) -> bool {
|
||||
name.strip_prefix(prefix)
|
||||
.is_some_and(|version| Version::parse(version).map_or(true, |version| version <= *current))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -10,7 +10,9 @@ 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 semver::Version;
|
||||
|
||||
use super::install::{InstallKind, Installer, Relaunch, Staged, is_stale_update, sibling};
|
||||
use super::manifest::Release;
|
||||
|
||||
pub struct AppImageInstaller {
|
||||
@@ -59,13 +61,13 @@ impl Installer for AppImageInstaller {
|
||||
})
|
||||
}
|
||||
|
||||
fn clean_up(&self) {
|
||||
fn clean_up(&self, current: &Version) {
|
||||
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) {
|
||||
if is_stale_update(&entry.file_name().to_string_lossy(), &prefix, current) {
|
||||
let _ = fs::remove_file(entry.path());
|
||||
}
|
||||
}
|
||||
@@ -115,13 +117,26 @@ mod tests {
|
||||
"stale",
|
||||
)
|
||||
.unwrap();
|
||||
installer.clean_up();
|
||||
let left: Vec<_> = fs::read_dir(folder.path())
|
||||
// Staged since start and waiting for the restart: kept.
|
||||
fs::write(
|
||||
sibling(&file, ".ShellRS-x86_64.AppImage.update-0.3.0"),
|
||||
"next",
|
||||
)
|
||||
.unwrap();
|
||||
installer.clean_up(&Version::new(0, 2, 0));
|
||||
let mut left: Vec<_> = fs::read_dir(folder.path())
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|entry| entry.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert_eq!(left.len(), 2, "{left:?}");
|
||||
assert!(left.contains(&"ShellRS-x86_64.AppImage".to_string()));
|
||||
left.sort();
|
||||
assert_eq!(
|
||||
left,
|
||||
[
|
||||
".ShellRS-x86_64.AppImage.update-0.3.0",
|
||||
"ShellRS-x86_64.AppImage",
|
||||
"downloaded"
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,11 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
|
||||
use semver::Version;
|
||||
|
||||
use super::build_info::APPLE_TEAM_ID;
|
||||
use super::error::UpdateError;
|
||||
use super::install::{InstallKind, Installer, Relaunch, Staged, sibling};
|
||||
use super::install::{InstallKind, Installer, Relaunch, Staged, is_stale_update, sibling};
|
||||
use super::manifest::Release;
|
||||
use crate::i18n::t;
|
||||
|
||||
@@ -217,7 +219,7 @@ impl Installer for MacInstaller {
|
||||
})
|
||||
}
|
||||
|
||||
fn clean_up(&self) {
|
||||
fn clean_up(&self, current: &Version) {
|
||||
let Some(folder) = self.bundle.parent() else {
|
||||
return;
|
||||
};
|
||||
@@ -227,7 +229,7 @@ impl Installer for MacInstaller {
|
||||
);
|
||||
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) {
|
||||
if name.starts_with(&old) || is_stale_update(&name, &update, current) {
|
||||
let _ = fs::remove_dir_all(entry.path());
|
||||
}
|
||||
}
|
||||
@@ -349,10 +351,40 @@ mod tests {
|
||||
assert_eq!(names.len(), 2, "{names:?}");
|
||||
assert!(names[0].starts_with(".ShellRS.app.old-"), "{names:?}");
|
||||
|
||||
installer.clean_up();
|
||||
// Run by 0.2.0, once it has started.
|
||||
installer.clean_up(&Version::new(0, 2, 0));
|
||||
assert_eq!(names_in(folder.path()), ["ShellRS.app"]);
|
||||
}
|
||||
|
||||
/// The clean-up runs a while after start; an update downloaded and
|
||||
/// staged before then is still there to install on restart.
|
||||
#[test]
|
||||
fn the_clean_up_keeps_an_update_staged_for_the_restart() {
|
||||
let (folder, bundle) = installed();
|
||||
let installer = MacInstaller::new(bundle.clone(), FakeTools::new("0.2.0", None));
|
||||
for stale in [
|
||||
".ShellRS.app.update-0.1.0",
|
||||
".ShellRS.app.update-later",
|
||||
".ShellRS.app.old-1",
|
||||
] {
|
||||
fs::create_dir(folder.path().join(stale)).unwrap();
|
||||
}
|
||||
let staged = installer
|
||||
.stage(Path::new("package.zip"), &release("0.2.0"))
|
||||
.unwrap();
|
||||
|
||||
installer.clean_up(&Version::new(0, 1, 0));
|
||||
assert_eq!(
|
||||
names_in(folder.path()),
|
||||
[".ShellRS.app.update-0.2.0", "ShellRS.app"]
|
||||
);
|
||||
assert_eq!(
|
||||
installer.apply(&staged, true),
|
||||
Ok(Relaunch::Restart(bundle.clone()))
|
||||
);
|
||||
assert_eq!(version_at(&bundle), "0.2.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_swap_puts_the_old_bundle_back() {
|
||||
let (folder, bundle) = installed();
|
||||
@@ -367,8 +399,12 @@ mod tests {
|
||||
Err(UpdateError::Install(_))
|
||||
));
|
||||
assert_eq!(version_at(&bundle), "0.1.0");
|
||||
installer.clean_up();
|
||||
assert_eq!(names_in(folder.path()), ["ShellRS.app"]);
|
||||
installer.clean_up(&Version::new(0, 1, 0));
|
||||
assert!(
|
||||
!names_in(folder.path())
|
||||
.iter()
|
||||
.any(|name| name.starts_with(".ShellRS.app.old-"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -484,7 +520,7 @@ mod tests {
|
||||
.check_signature(&bundle, Some("SHELLRS"))
|
||||
.is_err()
|
||||
);
|
||||
installer.clean_up();
|
||||
installer.clean_up(&Version::new(0, 2, 0));
|
||||
assert_eq!(names_in(&installed), ["ShellRS.app"]);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use semver::Version;
|
||||
|
||||
use super::error::UpdateError;
|
||||
use super::install::{InstallKind, Installer, Launch, Relaunch, Staged};
|
||||
use super::manifest::Release;
|
||||
@@ -99,7 +101,8 @@ impl Installer for SetupInstaller {
|
||||
}
|
||||
|
||||
/// The setup program removes what the previous version left.
|
||||
fn clean_up(&self) {}
|
||||
// The package waits in the downloads folder, which the updater tidies.
|
||||
fn clean_up(&self, _: &Version) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
//! 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 keys are part of the manifest format, so they never change. macOS
|
||||
//! packages match the running binary's architecture. Existing universal
|
||||
//! builds select their native half's package; an Intel build under Rosetta
|
||||
//! keeps receiving Intel packages until the user installs the ARM build.
|
||||
|
||||
/// The manifest's key for this build's platform.
|
||||
pub fn platform_key() -> &'static str {
|
||||
|
||||
@@ -507,7 +507,7 @@ impl Updater {
|
||||
return;
|
||||
};
|
||||
cx.background_spawn(async move {
|
||||
services.installer.clean_up();
|
||||
services.installer.clean_up(&services.current);
|
||||
clean_downloads(&services.folder, &services.current);
|
||||
})
|
||||
.detach();
|
||||
|
||||
+31
-9
@@ -319,7 +319,8 @@ pub(crate) mod tests {
|
||||
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-macos-aarch64.app.zip",
|
||||
"ShellRS-0.2.0-macos-x86_64.app.zip",
|
||||
"ShellRS-0.2.0-windows-x86_64-setup.exe",
|
||||
"ShellRS-0.2.0-linux-x86_64.AppImage",
|
||||
] {
|
||||
@@ -355,15 +356,36 @@ pub(crate) mod tests {
|
||||
};
|
||||
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"]);
|
||||
// dl.shellrs.com first, the GitHub release only when it fails.
|
||||
for arch in ["aarch64", "x86_64"] {
|
||||
let key = format!("macos-{arch}");
|
||||
let name = format!("ShellRS-0.2.0-{key}.app.zip");
|
||||
let asset = &manifest.assets[&key];
|
||||
// Each architecture has its own package and both download sources.
|
||||
assert_eq!(
|
||||
asset.urls,
|
||||
[
|
||||
format!("https://dl.shellrs.com/releases/0.2.0/{name}"),
|
||||
format!(
|
||||
"https://github.com/since2006/shell-rs/releases/download/v0.2.0/{name}"
|
||||
),
|
||||
]
|
||||
);
|
||||
assert_eq!(verify_file(&dist.join(&name), asset), Ok(()));
|
||||
assert_eq!(
|
||||
manifest.installers[&key],
|
||||
format!("https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-{key}.dmg")
|
||||
);
|
||||
}
|
||||
assert_ne!(
|
||||
manifest.assets["macos-aarch64"].sha256,
|
||||
manifest.assets["macos-x86_64"].sha256
|
||||
);
|
||||
assert_eq!(
|
||||
mac.urls,
|
||||
[
|
||||
"https://dl.shellrs.com/releases/0.2.0/ShellRS-0.2.0-macos-universal.app.zip",
|
||||
"https://github.com/since2006/shell-rs/releases/download/v0.2.0/ShellRS-0.2.0-macos-universal.app.zip",
|
||||
]
|
||||
verify_file(
|
||||
&dist.join("ShellRS-0.2.0-macos-x86_64.app.zip"),
|
||||
&manifest.assets["macos-aarch64"],
|
||||
),
|
||||
Err(UpdateError::Corrupt)
|
||||
);
|
||||
let package = dist.join("ShellRS-0.2.0-linux-x86_64.AppImage");
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! The workspace's share of the external CLI: coming forward when ShellRS
|
||||
//! is opened again, and the changes to hosts and credentials a `shellrs`
|
||||
//! command asks for. The request threads cannot reach the store; the
|
||||
//! changes wait in the server until they are made here, one at a time.
|
||||
//! is opened again, the changes to hosts and credentials a `shellrs`
|
||||
//! command asks for, and the `exec --terminal` runs. The request threads
|
||||
//! cannot reach the store or the terminals; the changes and the runs wait
|
||||
//! in the server until they are taken up here.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -14,12 +15,13 @@ use crate::cli::{
|
||||
};
|
||||
use crate::cli::{
|
||||
CredentialPlan, GroupPlan, SecretChange, credential_details, credential_secrets,
|
||||
find_credential, find_saved_host, host_details, host_info, host_secrets, plan_credential,
|
||||
plan_host, with_saved_credential_secrets, with_saved_passwords,
|
||||
find_credential, find_host, find_saved_host, host_details, host_info, host_secrets,
|
||||
plan_credential, plan_host, with_saved_credential_secrets, with_saved_passwords,
|
||||
};
|
||||
use crate::host::{CredentialId, GroupDraft, GroupId, HostId, HostStore};
|
||||
use crate::i18n::t;
|
||||
use crate::secrets::{SecretRef, SharedSecretStore};
|
||||
use crate::terminal::{RunFailure, RunHandle, TerminalLifecycle, TerminalPanel};
|
||||
|
||||
use super::workspace_view::Workspace;
|
||||
|
||||
@@ -48,6 +50,7 @@ impl Workspace {
|
||||
for usage in server.take_usage() {
|
||||
this.count(match usage {
|
||||
CliUse::Exec => Counter::CliExec,
|
||||
CliUse::ExecTerminal => Counter::CliExecTerminal,
|
||||
CliUse::Upload => Counter::CliUpload,
|
||||
CliUse::Download => Counter::CliDownload,
|
||||
CliUse::Sync => Counter::CliSync,
|
||||
@@ -73,6 +76,62 @@ impl Workspace {
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Hand each `exec --terminal` run the CLI server has heard to its
|
||||
/// host's terminal. Asked on a timer of its own: the poller above waits
|
||||
/// for each change to be made, and a run must not wait behind a
|
||||
/// keychain write.
|
||||
pub(super) fn serve_cli_runs(&self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
cx.spawn_in(window, async move |this, cx| {
|
||||
loop {
|
||||
cx.background_executor().timer(CLI_POLL).await;
|
||||
let served = this.update(cx, |this, cx| {
|
||||
let Some(server) = this.cli_server.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let runs: Vec<_> = std::iter::from_fn(|| server.take_run()).collect();
|
||||
for (host, run) in runs {
|
||||
if let Err(failure) = this.start_cli_run(&host, run.clone(), cx) {
|
||||
run.fail(failure);
|
||||
}
|
||||
}
|
||||
});
|
||||
if served.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
/// Type an `exec --terminal` run into the terminal of the host the CLI
|
||||
/// calls `host`: the one the right sidebar follows when it is this
|
||||
/// host's, else the host's newest connected one.
|
||||
pub fn start_cli_run(&self, host: &str, run: RunHandle, cx: &App) -> Result<(), RunFailure> {
|
||||
let host = find_host(self.store.read(cx), host)
|
||||
.map_err(|_| RunFailure::HostNotFound)?
|
||||
.id;
|
||||
let connected = |panel: &&Entity<TerminalPanel>| {
|
||||
matches!(panel.read(cx).lifecycle(cx), TerminalLifecycle::Running)
|
||||
};
|
||||
let panel = self
|
||||
.tool_terminal(cx)
|
||||
.filter(|terminal| terminal.host == host)
|
||||
.and_then(|terminal| self.terminals.get(&terminal.id))
|
||||
.filter(connected)
|
||||
.or_else(|| {
|
||||
self.terminals
|
||||
.values()
|
||||
.filter(|panel| panel.read(cx).host_id() == host)
|
||||
.filter(connected)
|
||||
.max_by_key(|panel| panel.read(cx).id().0)
|
||||
});
|
||||
match panel {
|
||||
Some(panel) => panel.read(cx).terminal().read(cx).run_command(run, cx),
|
||||
None if self.terminal(host, cx).is_some() => Err(RunFailure::NotConnected),
|
||||
None => Err(RunFailure::NoTerminal),
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a change a `shellrs` command asks for, checked as the host and
|
||||
/// credential forms check theirs. Its outcome is the reply the command
|
||||
/// prints; on success, the host or credential as it is now.
|
||||
|
||||
@@ -265,6 +265,7 @@ impl Workspace {
|
||||
Ok(server) => {
|
||||
this.cli_server = Some(server);
|
||||
this.serve_cli(window, cx);
|
||||
this.serve_cli_runs(window, cx);
|
||||
}
|
||||
Err(error) => notify_once_open(
|
||||
Notification::error(error.to_string()).title(t!("workspace.cli.start_failed")),
|
||||
|
||||
+206
@@ -4,6 +4,9 @@
|
||||
//! each change over.
|
||||
|
||||
use shellrs::cli::{CliChange, CliError, CredentialFields, ErrorCode, HostFields, Reply, Secret};
|
||||
use shellrs::terminal::{
|
||||
BusyReason, RunEvent, RunFailure, RunProgress, RunTiming, TerminalRun, TerminalView,
|
||||
};
|
||||
|
||||
use crate::support::*;
|
||||
|
||||
@@ -329,3 +332,206 @@ fn deleting_a_credential_lets_its_hosts_log_in_on_their_own(cx: &mut TestAppCont
|
||||
assert_eq!(host.user.as_ref(), "deploy");
|
||||
});
|
||||
}
|
||||
|
||||
/// How an `exec --terminal` run is paced against a fake shell that
|
||||
/// answers at once.
|
||||
const QUICK: RunTiming = RunTiming {
|
||||
settle: Duration::ZERO,
|
||||
probe: Duration::from_secs(2),
|
||||
start: Duration::from_secs(2),
|
||||
tick: Duration::from_millis(10),
|
||||
};
|
||||
|
||||
/// The seed with web-01's terminal running on a transport that behaves as
|
||||
/// `behavior`.
|
||||
async fn open_terminal(
|
||||
cx: &mut TestAppContext,
|
||||
behavior: FakeBehavior,
|
||||
) -> (
|
||||
WindowHandle<Root>,
|
||||
Entity<Workspace>,
|
||||
Arc<FakeTerminalFactory>,
|
||||
) {
|
||||
let factory = Arc::new(FakeTerminalFactory {
|
||||
behavior,
|
||||
..FakeTerminalFactory::default()
|
||||
});
|
||||
let (handle, workspace) =
|
||||
open_workspace_with_remote_factory(cx, HostStore::seed(), factory.clone());
|
||||
let terminal = RemoteTerminalId(INITIAL_WEB_TERMINAL);
|
||||
cx.wait_for(handle.into(), Duration::from_secs(5), |_, cx| {
|
||||
remote_lifecycle(&workspace, terminal, cx).accepts_input()
|
||||
})
|
||||
.await;
|
||||
(handle, workspace, factory)
|
||||
}
|
||||
|
||||
fn start_run(
|
||||
workspace: &Entity<Workspace>,
|
||||
host: &str,
|
||||
run: &TerminalRun,
|
||||
cx: &mut TestAppContext,
|
||||
) -> Result<(), RunFailure> {
|
||||
cx.update(|cx| workspace.read(cx).start_cli_run(host, run.handle(), cx))
|
||||
}
|
||||
|
||||
fn web_terminal(workspace: &Entity<Workspace>, cx: &mut TestAppContext) -> Entity<TerminalView> {
|
||||
cx.update(|cx| {
|
||||
workspace
|
||||
.read(cx)
|
||||
.remote_terminal(RemoteTerminalId(INITIAL_WEB_TERMINAL))
|
||||
.unwrap()
|
||||
.read(cx)
|
||||
.terminal()
|
||||
.clone()
|
||||
})
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn a_cli_run_is_typed_into_the_hosts_terminal_after_a_ctrl_c(cx: &mut TestAppContext) {
|
||||
let (_, workspace, factory) = open_terminal(
|
||||
cx,
|
||||
FakeBehavior::Shell {
|
||||
exe: "/usr/bin/bash",
|
||||
ends: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let web = host_id(&workspace, 1, cx);
|
||||
let before = factory.written_text().len();
|
||||
let (run, events) = TerminalRun::new("uname -a".into());
|
||||
assert_eq!(start_run(&workspace, &web, &run, cx), Ok(()));
|
||||
assert_eq!(events.recv().unwrap(), RunEvent::Accepted);
|
||||
|
||||
let (mut output, mut context) = (Vec::new(), None);
|
||||
let code = run.drive(&events, QUICK, &mut |step| {
|
||||
match step {
|
||||
RunProgress::Context(found) => context = Some(found),
|
||||
RunProgress::Output(bytes) => output.extend(bytes),
|
||||
RunProgress::Idle => {}
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
assert_eq!(code, Ok(0));
|
||||
assert_eq!(output, b"ran\n");
|
||||
let context = context.expect("begun");
|
||||
assert_eq!(
|
||||
(
|
||||
context.user.as_str(),
|
||||
context.cwd.as_str(),
|
||||
context.shell.as_str()
|
||||
),
|
||||
("root", "/root", "bash")
|
||||
);
|
||||
// ^C, the probe, the command line; never Ctrl-E, a byte to dash.
|
||||
let typed = factory.written_text()[before..].to_string();
|
||||
assert!(typed.starts_with("\x03 sh -c '"), "{typed:?}");
|
||||
assert!(typed.ends_with(" 'uname -a'\r"), "{typed:?}");
|
||||
assert_eq!(typed.matches(" sh -c '").count(), 2);
|
||||
assert!(!typed.contains('\x05'));
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn a_cli_run_a_terminal_cannot_take_says_why(cx: &mut TestAppContext) {
|
||||
let (_, workspace, factory) = open_terminal(
|
||||
cx,
|
||||
FakeBehavior::Shell {
|
||||
exe: "/usr/bin/zsh",
|
||||
ends: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let (other, _) = TerminalRun::new("true".into());
|
||||
assert_eq!(
|
||||
start_run(&workspace, "nope", &other, cx),
|
||||
Err(RunFailure::HostNotFound)
|
||||
);
|
||||
let db = host_id(&workspace, 3, cx);
|
||||
assert_eq!(
|
||||
start_run(&workspace, &db, &other, cx),
|
||||
Err(RunFailure::NoTerminal)
|
||||
);
|
||||
|
||||
// Another run has the terminal: no ^C to end it.
|
||||
let web = host_id(&workspace, 1, cx);
|
||||
let (first, first_events) = TerminalRun::new("sleep 9".into());
|
||||
assert_eq!(start_run(&workspace, &web, &first, cx), Ok(()));
|
||||
let before = factory.written_text().len();
|
||||
let (second, _) = TerminalRun::new("true".into());
|
||||
assert_eq!(
|
||||
start_run(&workspace, &web, &second, cx),
|
||||
Err(RunFailure::Busy(BusyReason::AnotherRun))
|
||||
);
|
||||
assert_eq!(factory.written_text().len(), before);
|
||||
|
||||
// A shell it cannot type into: only the probe was typed.
|
||||
assert_eq!(first_events.recv().unwrap(), RunEvent::Accepted);
|
||||
assert_eq!(
|
||||
first.drive(&first_events, QUICK, &mut |_| Ok(())),
|
||||
Err(RunFailure::UnsupportedShell("zsh".into()))
|
||||
);
|
||||
assert_eq!(
|
||||
factory.written_text()[before..].matches(" sh -c '").count(),
|
||||
1
|
||||
);
|
||||
|
||||
// The terminal disconnected.
|
||||
web_terminal(&workspace, cx).update(cx, |view, cx| view.stop("closed", cx));
|
||||
cx.run_until_parked();
|
||||
let (third, _) = TerminalRun::new("true".into());
|
||||
assert_eq!(
|
||||
start_run(&workspace, &web, &third, cx),
|
||||
Err(RunFailure::NotConnected)
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn a_cli_run_leaves_a_full_screen_program_alone(cx: &mut TestAppContext) {
|
||||
let (handle, workspace, factory) = open_terminal(cx, FakeBehavior::Prints("\x1b[?1049h")).await;
|
||||
let terminal = web_terminal(&workspace, cx);
|
||||
// The alternate screen is up: the prompt line is off it.
|
||||
cx.wait_for(handle.into(), Duration::from_secs(5), |_, cx| {
|
||||
!terminal.read(cx).screen_text(cx).contains("alpha.txt")
|
||||
})
|
||||
.await;
|
||||
let before = factory.written_text().len();
|
||||
let (run, _) = TerminalRun::new("true".into());
|
||||
let web = host_id(&workspace, 1, cx);
|
||||
assert_eq!(
|
||||
start_run(&workspace, &web, &run, cx),
|
||||
Err(RunFailure::Busy(BusyReason::FullScreen))
|
||||
);
|
||||
assert_eq!(factory.written_text().len(), before);
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn a_cli_run_whose_terminal_goes_away_may_have_run(cx: &mut TestAppContext) {
|
||||
let (_, workspace, factory) = open_terminal(
|
||||
cx,
|
||||
FakeBehavior::Shell {
|
||||
exe: "/usr/bin/bash",
|
||||
ends: false,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let web = host_id(&workspace, 1, cx);
|
||||
let (run, events) = TerminalRun::new("sleep 100".into());
|
||||
assert_eq!(start_run(&workspace, &web, &run, cx), Ok(()));
|
||||
assert_eq!(events.recv().unwrap(), RunEvent::Accepted);
|
||||
let (begun, begun_here) = mpsc::channel();
|
||||
let driver = std::thread::spawn(move || {
|
||||
run.drive(&events, QUICK, &mut |step| {
|
||||
if matches!(step, RunProgress::Context(_)) {
|
||||
let _ = begun.send(());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
begun_here
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.expect("the command started");
|
||||
web_terminal(&workspace, cx).update(cx, |view, cx| view.stop("closed", cx));
|
||||
cx.run_until_parked();
|
||||
assert_eq!(driver.join().unwrap(), Err(RunFailure::Disconnected));
|
||||
assert!(factory.written_text().contains(" 'sleep 100'\r"));
|
||||
}
|
||||
@@ -1271,6 +1271,50 @@ async fn sftp_tab_reconnects_from_its_tab_bar_and_reports_at_the_bottom_left(
|
||||
cx.update(|cx| assert_eq!(workspace.read(cx).explorers_of(HostId(DB_01), cx).len(), 2));
|
||||
}
|
||||
|
||||
/// The 目录列表 select's places, Windows drives among them, are read again
|
||||
/// with each local listing: a drive plugged in shows up on the next refresh.
|
||||
#[gpui_kit::test]
|
||||
async fn local_places_are_read_again_with_each_listing(cx: &mut TestAppContext) {
|
||||
use shellrs::app::{ExplorerAction, ExplorerCommand};
|
||||
use shellrs::sftp::{DriveKind, Place};
|
||||
let local = FakeLocalDirectory::default();
|
||||
let system = (Place::Drive('C', DriveKind::Local), "/c".to_string());
|
||||
local
|
||||
.places
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((system.0, system.1.clone().into()));
|
||||
let (handle, workspace) =
|
||||
open_workspace_with_services(cx, Arc::new(FakeSftpProvider::default()), local.clone());
|
||||
open_test_explorer(cx, handle).await;
|
||||
let places = |cx: &mut TestAppContext| {
|
||||
cx.update(|cx| {
|
||||
let explorer = workspace.read(cx).explorer(ExplorerId(SFTP_TAB)).unwrap();
|
||||
explorer.read(cx).local().read(cx).places().to_vec()
|
||||
})
|
||||
};
|
||||
assert_eq!(places(cx), std::slice::from_ref(&system));
|
||||
|
||||
let stick = (Place::Drive('E', DriveKind::Removable), "/e".to_string());
|
||||
local
|
||||
.places
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((stick.0, stick.1.clone().into()));
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(ExplorerAction::new(
|
||||
ExplorerId(SFTP_TAB),
|
||||
ExplorerCommand::Refresh { remote: false },
|
||||
)),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
cx.run_until_parked();
|
||||
assert_eq!(places(cx), [system, stick]);
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn sftp_file_commands_confirm_validate_and_send_one_operation(cx: &mut TestAppContext) {
|
||||
use shellrs::app::{ExplorerAction, ExplorerCommand};
|
||||
@@ -1514,6 +1558,139 @@ async fn sftp_dragging_outside_the_names_selects_the_rows_crossed(cx: &mut TestA
|
||||
cx.update(|cx| assert_eq!(pane_selection(&workspace, true, cx), ["目录", "链接目录"]));
|
||||
}
|
||||
|
||||
/// A click on a column title sorts by the column, as one on its arrow does:
|
||||
/// descending, ascending, then back to by name. The order outlives a
|
||||
/// refresh, after which the next click carries on from it.
|
||||
#[gpui_kit::test]
|
||||
async fn sftp_clicking_a_column_title_sorts_by_it(cx: &mut TestAppContext) {
|
||||
use shellrs::app::{ExplorerAction, ExplorerCommand};
|
||||
let provider = Arc::new(FakeSftpProvider::default());
|
||||
let (handle, workspace) = open_workspace_with_sftp(cx, provider);
|
||||
open_test_explorer(cx, handle).await;
|
||||
let pane = ("local-pane", SFTP_TAB);
|
||||
let names = |cx: &mut TestAppContext| {
|
||||
cx.run_until_parked();
|
||||
cx.update(|cx| {
|
||||
let explorer = workspace.read(cx).explorer(ExplorerId(SFTP_TAB)).unwrap();
|
||||
let entries = explorer.read(cx).local().read(cx).entries(cx);
|
||||
entries
|
||||
.into_iter()
|
||||
.map(|entry| entry.name.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
};
|
||||
let click_size = |cx: &mut TestAppContext| {
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
window.within(pane).click("column:size", cx);
|
||||
})
|
||||
.unwrap();
|
||||
};
|
||||
let by_name = ["..", "目录", "链接目录", "文件 乙.txt", "文件 甲.txt"];
|
||||
assert_eq!(names(cx), by_name);
|
||||
|
||||
// Folders stay ahead of files whichever way.
|
||||
click_size(cx);
|
||||
let descending = ["..", "链接目录", "目录", "文件 乙.txt", "文件 甲.txt"];
|
||||
assert_eq!(names(cx), descending);
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(ExplorerAction::new(
|
||||
ExplorerId(SFTP_TAB),
|
||||
ExplorerCommand::Refresh { remote: false },
|
||||
)),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(names(cx), descending);
|
||||
|
||||
click_size(cx);
|
||||
assert_eq!(
|
||||
names(cx),
|
||||
["..", "目录", "链接目录", "文件 甲.txt", "文件 乙.txt"]
|
||||
);
|
||||
click_size(cx);
|
||||
assert_eq!(names(cx), by_name);
|
||||
}
|
||||
|
||||
/// A column dragged wider stays so with the next listing, which gives the
|
||||
/// table its columns again.
|
||||
#[gpui_kit::test]
|
||||
async fn sftp_column_widths_outlive_a_refresh(cx: &mut TestAppContext) {
|
||||
use gpui_kit::{Modifiers, MouseDownEvent, MouseUpEvent};
|
||||
use shellrs::app::{ExplorerAction, ExplorerCommand};
|
||||
let provider = Arc::new(FakeSftpProvider::default());
|
||||
let (handle, _workspace) = open_workspace_with_sftp(cx, provider);
|
||||
open_test_explorer(cx, handle).await;
|
||||
let pane = ("local-pane", SFTP_TAB);
|
||||
let width = |cx: &mut TestAppContext| {
|
||||
cx.run_until_parked();
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
window
|
||||
.within(pane)
|
||||
.find(("col-header", 1usize))
|
||||
.bounds()
|
||||
.size
|
||||
.width
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let before = width(cx);
|
||||
// Drag the boundary right of 大小 by 60 pixels.
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.render_frame(cx);
|
||||
let bounds = window.within(pane).find(("col-header", 1usize)).bounds();
|
||||
let start = point(bounds.right() - px(1.), bounds.center().y);
|
||||
let event = |x: f32| MouseMoveEvent {
|
||||
position: point(start.x + px(x), start.y),
|
||||
pressed_button: Some(MouseButton::Left),
|
||||
modifiers: Modifiers::default(),
|
||||
};
|
||||
window.dispatch_event(
|
||||
MouseDownEvent {
|
||||
button: MouseButton::Left,
|
||||
position: start,
|
||||
modifiers: Modifiers::default(),
|
||||
click_count: 1,
|
||||
first_mouse: false,
|
||||
}
|
||||
.to_platform_input(),
|
||||
cx,
|
||||
);
|
||||
for x in [10., 30., 60.] {
|
||||
window.dispatch_event(event(x).to_platform_input(), cx);
|
||||
window.render_frame(cx);
|
||||
}
|
||||
window.dispatch_event(
|
||||
MouseUpEvent {
|
||||
button: MouseButton::Left,
|
||||
position: point(start.x + px(60.), start.y),
|
||||
modifiers: Modifiers::default(),
|
||||
click_count: 1,
|
||||
}
|
||||
.to_platform_input(),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
let dragged = width(cx);
|
||||
assert!(dragged > before + px(40.), "{before:?} -> {dragged:?}");
|
||||
|
||||
cx.update_window(handle.into(), |_, window, cx| {
|
||||
window.dispatch_action(
|
||||
Box::new(ExplorerAction::new(
|
||||
ExplorerId(SFTP_TAB),
|
||||
ExplorerCommand::Refresh { remote: false },
|
||||
)),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(width(cx), dragged);
|
||||
}
|
||||
|
||||
/// As in WinSCP without full row select, only the name cell is the item: a
|
||||
/// click on the rest of a row, or below the rows, is a click on empty space
|
||||
/// and clears the selection. A ⌘ click there, or a click on a column title,
|
||||
|
||||
@@ -271,6 +271,8 @@ pub struct FakeLocalDirectory {
|
||||
pub calls: Arc<Mutex<Vec<String>>>,
|
||||
/// The files the editor reads and writes, in memory.
|
||||
pub files: Arc<Mutex<std::collections::BTreeMap<std::path::PathBuf, Vec<u8>>>>,
|
||||
/// The places it offers, such as a drive plugged in.
|
||||
pub places: Arc<Mutex<Vec<(shellrs::sftp::Place, std::path::PathBuf)>>>,
|
||||
}
|
||||
|
||||
impl FakeLocalDirectory {
|
||||
@@ -287,6 +289,9 @@ impl LocalDirectoryProvider for FakeLocalDirectory {
|
||||
fn list(&self, path: &std::path::Path) -> anyhow::Result<DirectoryListing> {
|
||||
Ok(fake_listing(path.to_str().unwrap()))
|
||||
}
|
||||
fn places(&self) -> Vec<(shellrs::sftp::Place, std::path::PathBuf)> {
|
||||
self.places.lock().unwrap().clone()
|
||||
}
|
||||
fn trash(&self, paths: &[std::path::PathBuf]) -> anyhow::Result<()> {
|
||||
self.record(format!("trash {paths:?}"))
|
||||
}
|
||||
|
||||
@@ -12,6 +12,13 @@ pub enum FakeBehavior {
|
||||
ReportsLatency(Latency),
|
||||
/// Prints this after its prompt line, as a program would.
|
||||
Prints(&'static str),
|
||||
/// Answers what `exec --terminal` types the way a shell whose program
|
||||
/// is `exe` would: the probe, then the command line, which prints
|
||||
/// `ran` and ends with 0 when `ends`, and runs on otherwise.
|
||||
Shell {
|
||||
exe: &'static str,
|
||||
ends: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -165,7 +172,11 @@ impl TerminalTransport for FakeTerminalTransport {
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.push(bytes.clone());
|
||||
let answer = shell_answer(self.behavior, &bytes);
|
||||
events.send_blocking(TerminalTransportEvent::Output(bytes))?;
|
||||
if let Some(answer) = answer {
|
||||
events.send_blocking(TerminalTransportEvent::Output(answer))?;
|
||||
}
|
||||
}
|
||||
TerminalTransportCommand::Resize(size) => {
|
||||
self.resizes
|
||||
@@ -199,6 +210,35 @@ impl TerminalTransport for FakeTerminalTransport {
|
||||
}
|
||||
}
|
||||
|
||||
/// What a [`FakeBehavior::Shell`] prints for a line `exec --terminal`
|
||||
/// typed (` sh -c '<script>' sh NONCE …`): the probe's marker, or the
|
||||
/// command's, run in /root on web-01 as root.
|
||||
fn shell_answer(behavior: FakeBehavior, typed: &[u8]) -> Option<Vec<u8>> {
|
||||
let FakeBehavior::Shell { exe, ends } = behavior else {
|
||||
return None;
|
||||
};
|
||||
let line = String::from_utf8_lossy(typed);
|
||||
let at = line.find("' sh ")?;
|
||||
let nonce = line.get(at + 5..at + 17)?;
|
||||
let answer = if line.contains("6973;P;") {
|
||||
format!("\x1b]6973;P;{nonce};{exe}\x07")
|
||||
} else if line.contains("6973;B;") {
|
||||
let context: String = "root\nweb-01\n/root\nbash"
|
||||
.bytes()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
let begin = format!("\x1b]6973;B;{nonce};{context}\x07");
|
||||
if ends {
|
||||
format!("{begin}ran\r\n\x1b]6973;E;{nonce};0\x07")
|
||||
} else {
|
||||
begin
|
||||
}
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(answer.into_bytes())
|
||||
}
|
||||
|
||||
pub fn open_workspace_with_remote_factory(
|
||||
cx: &mut TestAppContext,
|
||||
store: HostStore,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! the font and the size, links, the mouse for programs and their copies.
|
||||
|
||||
use crate::support::*;
|
||||
use shellrs::terminal::RightClick;
|
||||
|
||||
fn open_workspace_with_factory(
|
||||
cx: &mut TestAppContext,
|
||||
@@ -363,6 +364,93 @@ async fn terminal_selection_copies_only_on_command_and_finishes_outside_view(
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn copy_on_select_puts_what_the_mouse_selects_on_the_clipboard(cx: &mut TestAppContext) {
|
||||
let factory = Arc::new(FakeTerminalFactory::default());
|
||||
let (handle, workspace) = open_running_local_terminal(cx, factory).await;
|
||||
let settings = cx.update(|cx| workspace.read(cx).settings().clone());
|
||||
cx.wait_for(handle.into(), Duration::from_secs(2), |window, cx| {
|
||||
window.render_frame(cx);
|
||||
local_screen(&workspace, cx).contains("会议纪要")
|
||||
})
|
||||
.await;
|
||||
|
||||
// 终端交互 is the second group of 终端: 选中复制, off, then 右键行为.
|
||||
in_frame(cx, handle, |window, cx| window.click("open-settings", cx));
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.within("settings").click("0-1", cx)
|
||||
});
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
let mut settings = window.within("settings");
|
||||
let mut group = settings.within("group-1");
|
||||
assert_eq!(group.within("item-0").find("check").checked(), Some(false));
|
||||
assert_eq!(group.within("item-1").find("btn").label(), Some("显示菜单"));
|
||||
group.within("item-0").click("check", cx);
|
||||
});
|
||||
cx.update(|cx| {
|
||||
assert!(
|
||||
settings
|
||||
.read(cx)
|
||||
.settings()
|
||||
.terminal_interaction
|
||||
.copy_on_select
|
||||
)
|
||||
});
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.click(("local-terminal-tab", 1_u64), cx)
|
||||
});
|
||||
|
||||
cx.update(|cx| cx.write_to_clipboard(ClipboardItem::new_string("原剪贴板".into())));
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
let bounds = window.find(("local-terminal", 1_u64)).bounds();
|
||||
let from = point(bounds.left() + px(1.), bounds.top() + px(8.));
|
||||
let to = point(bounds.right() - px(1.), bounds.top() + px(8.));
|
||||
// A click selects nothing, and the clipboard keeps what it had.
|
||||
click_at(window, from, gpui_kit::Modifiers::none(), cx);
|
||||
assert_eq!(
|
||||
cx.read_from_clipboard().and_then(|item| item.text()),
|
||||
Some("原剪贴板".into())
|
||||
);
|
||||
window.drag(from, to, cx);
|
||||
assert_eq!(
|
||||
cx.read_from_clipboard().and_then(|item| item.text()),
|
||||
Some("run:1$ alpha.txt 会议纪要.md".into())
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn right_click_pastes_or_does_nothing_when_set_to(cx: &mut TestAppContext) {
|
||||
let factory = Arc::new(FakeTerminalFactory::default());
|
||||
let (handle, workspace) = open_running_local_terminal(cx, factory.clone()).await;
|
||||
let settings = cx.update(|cx| workspace.read(cx).settings().clone());
|
||||
let right_click = |cx: &mut TestAppContext, action: RightClick, clipboard: &str| {
|
||||
cx.update(|cx| {
|
||||
settings.update(cx, |settings, cx| {
|
||||
settings.update(
|
||||
|settings| settings.terminal_interaction.right_click = action,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
cx.write_to_clipboard(ClipboardItem::new_string(clipboard.into()));
|
||||
});
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
window.right_click(("local-terminal", 1_u64), cx)
|
||||
});
|
||||
in_frame(cx, handle, |window, _| {
|
||||
assert!(window.try_find("popup-menu").is_none())
|
||||
});
|
||||
};
|
||||
|
||||
right_click(cx, RightClick::Paste, "粘贴一次");
|
||||
take_written(cx, handle, &factory, "粘贴一次").await;
|
||||
|
||||
// Nothing is pasted: had it been, the next paste would follow it.
|
||||
right_click(cx, RightClick::Nothing, "不该粘贴");
|
||||
right_click(cx, RightClick::Paste, "再粘贴");
|
||||
take_written(cx, handle, &factory, "再粘贴").await;
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn cjk_input_is_sent_once_and_resize_commands_are_deduplicated(cx: &mut TestAppContext) {
|
||||
let factory = Arc::new(FakeTerminalFactory::default());
|
||||
@@ -921,6 +1009,25 @@ async fn programs_that_ask_for_the_mouse_hear_clicks_and_the_wheel(cx: &mut Test
|
||||
take_written(cx, handle, &factory, "\x1b[<64;5;2M").await;
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn the_scrollbar_scrolls_back_through_the_history(cx: &mut TestAppContext) {
|
||||
let text: String = (1..=200).map(|line| format!("line {line}\r\n")).collect();
|
||||
let (handle, workspace, _) = terminal_printing(cx, String::leak(text + "end"), "end").await;
|
||||
let screen = |cx: &mut TestAppContext| cx.read(|cx| local_screen(&workspace, cx));
|
||||
assert!(!screen(cx).lines().any(|line| line == "line 1"));
|
||||
|
||||
// A click at the top of the scrollbar's track, in the margin beside the
|
||||
// text, goes to the oldest line.
|
||||
in_frame(cx, handle, |window, cx| {
|
||||
let terminal = window.find(("local-terminal", 1_u64)).bounds();
|
||||
let top = point(terminal.right() + px(2.), terminal.top() + px(2.));
|
||||
click_at(window, top, gpui_kit::Modifiers::none(), cx);
|
||||
});
|
||||
let lines = screen(cx);
|
||||
assert!(lines.lines().any(|line| line == "line 1"), "{lines}");
|
||||
assert!(!lines.contains("end"), "{lines}");
|
||||
}
|
||||
|
||||
#[gpui_kit::test]
|
||||
async fn a_program_copies_to_the_local_clipboard(cx: &mut TestAppContext) {
|
||||
// 「你好」 in base64.
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@ impl Installer for FakeInstaller {
|
||||
})
|
||||
}
|
||||
|
||||
fn clean_up(&self) {}
|
||||
fn clean_up(&self, _: &Version) {}
|
||||
}
|
||||
|
||||
/// A copy of ShellRS that can install updates itself.
|
||||
|
||||
Reference in new issue
Block a user