This commit is contained in:
2026-06-19 03:09:53 +08:00
Unverified
commit d32882443f
49 changed files with 19399 additions and 0 deletions

View File

@@ -0,0 +1,500 @@
name: Release
# Gitea Actions workflow:
# - pushes to main build the Windows GNU package and publish a build-date Release.
# - tag pushes (v*) build the Windows GNU package and publish the matching Release.
# - manual runs build the same package and publish a build-date Release.
on:
push:
branches: ["main"]
tags: ["v*"]
workflow_dispatch:
env:
HTTP_PROXY: "http://10.31.88.88:7891"
HTTPS_PROXY: "http://10.31.88.88:7891"
NO_PROXY: "localhost,127.0.0.1,.local,*.local"
permissions:
contents: write
jobs:
build-windows-gnu:
name: windows-x86_64-gnu
runs-on: Ubuntu_docker_act
env:
TARGET: x86_64-pc-windows-gnu
BIN: meatshell.exe
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER: x86_64-w64-mingw32-gcc
CARGO_TARGET_X86_64_PC_WINDOWS_GNU_AR: x86_64-w64-mingw32-ar
CC_x86_64_pc_windows_gnu: x86_64-w64-mingw32-gcc
CXX_x86_64_pc_windows_gnu: x86_64-w64-mingw32-g++
AR_x86_64_pc_windows_gnu: x86_64-w64-mingw32-ar
WINDRES: x86_64-w64-mingw32-windres
PKG_CONFIG_ALLOW_CROSS: "1"
steps:
- name: 显示代理配置
shell: bash
run: |
echo "=== Proxy Configuration ==="
echo "HTTP_PROXY: ${HTTP_PROXY}"
echo "HTTPS_PROXY: ${HTTPS_PROXY}"
echo "NO_PROXY: ${NO_PROXY}"
echo "=========================="
- name: 测试代理连通性
shell: bash
run: |
echo "Testing proxy connectivity..."
if curl -x "${HTTP_PROXY}" -I --connect-timeout 10 https://google.com 2>&1 | grep -q "HTTP"; then
echo "✅ Proxy is working!"
else
echo "⚠️ Proxy test failed, but continuing..."
fi
- name: 配置 Git 全局代理
shell: bash
run: |
git config --global http.proxy ${HTTP_PROXY}
git config --global https.proxy ${HTTPS_PROXY}
git config --global http.sslVerify false
git config --global https.sslVerify false
echo "✅ Git proxy configured"
- name: 配置 Cargo 代理
shell: bash
run: |
mkdir -p ~/.cargo
printf '[http]\n' > ~/.cargo/config.toml
printf 'proxy = "%s"\n' "${HTTP_PROXY}" >> ~/.cargo/config.toml
printf 'check-revoke = false\n\n' >> ~/.cargo/config.toml
printf '[https]\n' >> ~/.cargo/config.toml
printf 'proxy = "%s"\n' "${HTTPS_PROXY}" >> ~/.cargo/config.toml
printf 'check-revoke = false\n\n' >> ~/.cargo/config.toml
printf '[net]\n' >> ~/.cargo/config.toml
printf 'git-fetch-with-cli = true\n' >> ~/.cargo/config.toml
printf 'retry = 10\n' >> ~/.cargo/config.toml
echo "✅ Cargo proxy configured"
cat ~/.cargo/config.toml
- name: 配置 Cargo 环境变量
shell: bash
run: |
echo "CARGO_HTTP_PROXY=${HTTP_PROXY}" >> $GITHUB_ENV
echo "CARGO_HTTPS_PROXY=${HTTPS_PROXY}" >> $GITHUB_ENV
echo "CARGO_HTTP_SSL_VERIFY=false" >> $GITHUB_ENV
echo "CARGO_HTTPS_SSL_VERIFY=false" >> $GITHUB_ENV
- name: 配置 curl 默认代理
shell: bash
run: |
mkdir -p ~/.curl
printf 'proxy = %s\n' "${HTTP_PROXY}" > ~/.curlrc
printf 'insecure\n' >> ~/.curlrc
echo "✅ curl proxy configured"
- uses: actions/checkout@v4
with:
clean: false
- name: Cache Cargo registry and target
uses: actions/cache@v3
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-${{ env.TARGET }}-cargo-${{ hashFiles('Cargo.lock') }}
restore-keys: |
${{ runner.os }}-${{ env.TARGET }}-cargo-
${{ runner.os }}-cargo-
- name: Configure local Cargo cache
shell: bash
run: |
set -eu
CACHE_ROOT="${MEATSHELL_CI_CACHE:-$PWD/.cache/ci}"
case "$CACHE_ROOT" in
/*) ;;
*) CACHE_ROOT="$PWD/$CACHE_ROOT" ;;
esac
mkdir -p "$CACHE_ROOT/cargo" "$CACHE_ROOT/target"
echo "CARGO_HOME=$CACHE_ROOT/cargo" >> "$GITHUB_ENV"
echo "CARGO_TARGET_DIR=$CACHE_ROOT/target" >> "$GITHUB_ENV"
echo "CARGO_NET_RETRY=10" >> "$GITHUB_ENV"
echo "Using Cargo cache root: $CACHE_ROOT"
if [ -d "$CACHE_ROOT/target/$TARGET/release" ]; then
echo "✅ Cache hit! Previous build artifacts found."
echo "Release binary size: $(du -sh "$CACHE_ROOT/target/$TARGET/release/$BIN" 2>/dev/null | cut -f1 || echo 'not built yet')"
else
echo "❌ Cache miss or first run. Will perform full build."
fi
- name: 清理可能损坏的缓存
shell: bash
run: |
if [ -d ~/.cargo/registry/index/github.com-* ]; then
echo "Cleaning corrupted cargo registry cache..."
rm -rf ~/.cargo/registry/index/github.com-*
fi
- name: Show toolchain
shell: bash
run: |
set -eu
rustc --version
cargo --version
x86_64-w64-mingw32-gcc --version | head -n 1
x86_64-w64-mingw32-windres --version | head -n 1
- name: Ensure Rust target
shell: bash
run: rustup target add "$TARGET"
- name: Update crates.io index with retry
shell: bash
run: |
for i in 1 2 3 4 5; do
echo "Attempt $i to fetch dependencies..."
if cargo fetch 2>&1; then
echo "✅ Cargo fetch succeeded on attempt $i"
break
fi
if [ $i -eq 5 ]; then
echo "❌ Cargo fetch failed after 5 attempts"
exit 1
fi
echo "Waiting 10 seconds before retry..."
sleep 10
done
- name: UI terminal smoke test
shell: bash
run: |
set -eu
BUILD_DIR="${CARGO_TARGET_DIR:-target}"
cargo build
rm -f "$BUILD_DIR/debug/meatshell-debug.log" meatshell-debug.log
SMOKE_OUTPUT="$BUILD_DIR/debug/meatshell-smoke.out"
set +e
MEATSHELL_SMOKE_TERMINAL=1 timeout 8s xvfb-run -a "$BUILD_DIR/debug/meatshell" >"$SMOKE_OUTPUT" 2>&1
code=$?
set -e
if [ "$code" -ne 0 ]; then
cat "$SMOKE_OUTPUT"
if [ -f "$BUILD_DIR/debug/meatshell-debug.log" ]; then
cat "$BUILD_DIR/debug/meatshell-debug.log"
fi
exit "$code"
fi
if [ -f "$BUILD_DIR/debug/meatshell-debug.log" ]; then
if grep -E "ERROR|panic|Recursion detected" "$BUILD_DIR/debug/meatshell-debug.log"; then
cat "$SMOKE_OUTPUT"
cat "$BUILD_DIR/debug/meatshell-debug.log"
exit 1
fi
fi
- name: Build Windows GNU release
shell: bash
run: |
set -eu
if command -v build-win-gnu >/dev/null 2>&1; then
build-win-gnu
else
cargo build --release --target "$TARGET"
fi
- name: Package Windows zip
shell: bash
env:
EVENT_NAME: ${{ gitea.event_name }}
REF: ${{ gitea.ref }}
REF_NAME: ${{ gitea.ref_name }}
run: |
set -eu
BUILD_DIR="${CARGO_TARGET_DIR:-target}"
event_name="${EVENT_NAME:-${GITHUB_EVENT_NAME:-}}"
ref="${REF:-${GITHUB_REF:-}}"
ref_name="${REF_NAME:-${GITHUB_REF_NAME:-dev}}"
short_sha="$(git rev-parse --short=12 HEAD)"
if [ "$event_name" = "push" ] && [ "$ref" = "refs/tags/${ref_name}" ]; then
VERSION="${ref_name}-${short_sha}"
RELEASE_TAG="${ref_name}"
elif [ "$event_name" = "push" ] && [ "$ref" != "refs/heads/main" ]; then
VERSION="${ref_name}-${short_sha}"
RELEASE_TAG="${ref_name}"
else
BUILD_STAMP="$(date -u +%Y%m%d-%H%M%S)"
VERSION="build-${BUILD_STAMP}-${short_sha}"
RELEASE_TAG="$VERSION"
fi
RELEASE_NAME="$RELEASE_TAG"
STAGE="meatshell-${VERSION}-windows-x86_64-gnu"
mkdir -p "$STAGE"
cp "${BUILD_DIR}/${TARGET}/release/${BIN}" "$STAGE/"
cp README.md README.en.md CHANGELOG.md "$STAGE/" 2>/dev/null || true
if command -v 7z >/dev/null 2>&1; then
7z a "${STAGE}.zip" "$STAGE" >/dev/null
else
zip -r "${STAGE}.zip" "$STAGE"
fi
echo "ASSET=${STAGE}.zip" >> "$GITHUB_ENV"
echo "RELEASE_TAG=${RELEASE_TAG}" >> "$GITHUB_ENV"
echo "RELEASE_NAME=${RELEASE_NAME}" >> "$GITHUB_ENV"
- name: Select release token
shell: bash
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
WORKFLOW_TOKEN: ${{ gitea.token }}
run: |
set -eu
token="${RELEASE_TOKEN:-${GITEA_TOKEN:-${WORKFLOW_TOKEN:-}}}"
if [ -z "$token" ]; then
echo "No release token available; set RELEASE_TOKEN or GITEA_TOKEN." >&2
exit 1
fi
echo "::add-mask::$token"
echo "RELEASE_API_KEY=$token" >> "$GITHUB_ENV"
- name: Publish Gitea release
shell: bash
env:
API_TOKEN: ${{ env.RELEASE_API_KEY }}
RELEASE_TAG_ENV: ${{ env.RELEASE_TAG }}
RELEASE_NAME_ENV: ${{ env.RELEASE_NAME }}
EVENT_NAME: ${{ gitea.event_name }}
REF: ${{ gitea.ref }}
REF_NAME: ${{ gitea.ref_name }}
RUN_ID: ${{ gitea.run_id }}
RUN_NUMBER: ${{ gitea.run_number }}
GITEA_SERVER_URL_CTX: ${{ gitea.server_url }}
GITEA_REPOSITORY_CTX: ${{ gitea.repository }}
GITHUB_SERVER_URL_CTX: ${{ github.server_url }}
GITHUB_REPOSITORY_CTX: ${{ github.repository }}
run: |
set -euo pipefail
asset="${ASSET:?ASSET is not set}"
event_name="${EVENT_NAME:-${GITHUB_EVENT_NAME:-}}"
ref="${REF:-${GITHUB_REF:-}}"
if [ -n "${RELEASE_TAG_ENV:-}" ]; then
tag="$RELEASE_TAG_ENV"
release_name="${RELEASE_NAME_ENV:-$tag}"
release_body="Automated Windows GNU build for ${tag}."
target_commitish="$(git rev-parse HEAD)"
elif [ "$event_name" = "push" ] && [ "$ref" = "refs/heads/main" ]; then
short_sha="$(git rev-parse --short=12 HEAD)"
stamp="$(date -u +%Y%m%d-%H%M%S)"
tag="build-${stamp}-${short_sha}"
release_name="$tag"
release_body="Automated Windows GNU build for main commit ${short_sha}."
target_commitish="$(git rev-parse HEAD)"
elif [ "$event_name" = "workflow_dispatch" ]; then
short_sha="$(git rev-parse --short=12 HEAD)"
run_id="${RUN_ID:-${GITHUB_RUN_ID:-${RUN_NUMBER:-${GITHUB_RUN_NUMBER:-$(date +%Y%m%d%H%M%S)}}}}"
tag="manual-${run_id}"
release_name="$tag"
release_body="Manual Windows GNU build for commit ${short_sha}."
target_commitish="$(git rev-parse HEAD)"
else
tag="${REF_NAME:-${GITHUB_REF_NAME:-}}"
release_name="$tag"
release_body="Automated Windows GNU build for ${tag}."
target_commitish="$(git rev-parse HEAD)"
fi
token="${API_TOKEN:-${RELEASE_API_KEY:-}}"
server="${GITEA_SERVER_URL_CTX:-${GITHUB_SERVER_URL_CTX:-${GITHUB_SERVER_URL:-}}}"
repo="${GITEA_REPOSITORY_CTX:-${GITHUB_REPOSITORY_CTX:-${GITHUB_REPOSITORY:-}}}"
if [ -z "$token" ]; then
echo "RELEASE_API_KEY is not set." >&2
exit 1
fi
if [ -z "$tag" ]; then
echo "Tag name is empty." >&2
exit 1
fi
if [ ! -f "$asset" ]; then
echo "Release asset not found: $asset" >&2
exit 1
fi
if [ -z "$server" ] || [ -z "$repo" ]; then
read -r server repo < <(python3 - <<'PY'
import re
import subprocess
from urllib.parse import urlparse
remote = subprocess.check_output(
["git", "remote", "get-url", "origin"], text=True
).strip()
if remote.startswith(("http://", "https://")):
parsed = urlparse(remote)
path = parsed.path.strip("/")
if path.endswith(".git"):
path = path[:-4]
print(f"{parsed.scheme}://{parsed.netloc} {path}")
else:
match = re.match(r"[^@]+@([^:]+):(.+)$", remote)
if not match:
raise SystemExit(f"cannot parse git remote: {remote}")
path = match.group(2)
if path.endswith(".git"):
path = path[:-4]
print(f"https://{match.group(1)} {path}")
PY
)
fi
server="${server%/}"
api="$server/api/v1/repos/$repo"
auth_header="Authorization: token $token"
curl_api() {
attempt=1
while true; do
if curl --proxy "${HTTP_PROXY:-}" \
--insecure \
--fail --silent --show-error --location \
--connect-timeout 20 --max-time 180 \
-H "$auth_header" "$@"; then
return 0
fi
if [ "$attempt" -ge 8 ]; then
return 1
fi
sleep $((attempt * 3))
attempt=$((attempt + 1))
done
}
curl_status() {
out="$1"
shift
attempt=1
while true; do
status="$(
curl --proxy "${HTTP_PROXY:-}" \
--insecure \
--silent --show-error --location \
--connect-timeout 20 --max-time 180 \
-H "$auth_header" \
-o "$out" -w "%{http_code}" \
"$@" || true
)"
if [ -n "$status" ] && [ "$status" != "000" ]; then
printf '%s' "$status"
return 0
fi
if [ "$attempt" -ge 8 ]; then
printf '%s' "${status:-000}"
return 1
fi
sleep $((attempt * 3))
attempt=$((attempt + 1))
done
}
echo "Checking Gitea API: $server/api/v1/version"
curl_api "$server/api/v1/version" >/dev/null
release_json="$(mktemp)"
status="$(curl_status "$release_json" "$api/releases/tags/$tag" || true)"
if [ "$status" = "404" ]; then
payload="$(
TAG="$tag" RELEASE_NAME="$release_name" RELEASE_BODY="$release_body" TARGET_COMMITISH="$target_commitish" python3 - <<'PY'
import json
import os
tag = os.environ["TAG"]
print(json.dumps({
"tag_name": tag,
"target_commitish": os.environ["TARGET_COMMITISH"],
"name": os.environ["RELEASE_NAME"],
"body": os.environ["RELEASE_BODY"],
"draft": False,
"prerelease": False,
}))
PY
)"
status="$(
curl_status "$release_json" \
-H "Content-Type: application/json" \
-X POST -d "$payload" \
"$api/releases" || true
)"
fi
if [ "$status" != "200" ] && [ "$status" != "201" ]; then
echo "Release lookup/create failed with HTTP $status" >&2
cat "$release_json" >&2 || true
exit 1
fi
release_id="$(python3 - "$release_json" <<'PY'
import json
import sys
with open(sys.argv[1], encoding="utf-8") as fh:
print(json.load(fh)["id"])
PY
)"
update_payload="$(
RELEASE_NAME="$release_name" RELEASE_BODY="$release_body" python3 - <<'PY'
import json
import os
print(json.dumps({
"name": os.environ["RELEASE_NAME"],
"body": os.environ["RELEASE_BODY"],
"draft": False,
"prerelease": False,
}))
PY
)"
curl_api -H "Content-Type: application/json" \
-X PATCH -d "$update_payload" \
"$api/releases/$release_id" >/dev/null
asset_name="$(basename "$asset")"
asset_id="$(
ASSET_NAME="$asset_name" python3 - "$release_json" <<'PY'
import json
import os
import sys
with open(sys.argv[1], encoding="utf-8") as fh:
release = json.load(fh)
assets = release.get("assets") or release.get("attachments") or []
for item in assets:
if item.get("name") == os.environ["ASSET_NAME"]:
print(item["id"])
break
PY
)"
if [ -n "$asset_id" ]; then
echo "Deleting existing release asset: $asset_name"
curl_api -X DELETE "$api/releases/$release_id/assets/$asset_id" >/dev/null
fi
asset_query="$(
ASSET_NAME="$asset_name" python3 - <<'PY'
import os
import urllib.parse
print(urllib.parse.quote(os.environ["ASSET_NAME"]))
PY
)"
echo "Uploading release asset: $asset_name"
curl_api -X POST \
-F "attachment=@${asset}" \
"$api/releases/$release_id/assets?name=$asset_query" >/dev/null
echo "✅ Release published successfully through proxy"

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
/target
Cargo.lock
*.rs.bk
meatshell-debug.log
release-*.log
.DS_Store
/.vscode
/.idea
/.claude
/.cache

192
CHANGELOG.md Normal file
View File

@@ -0,0 +1,192 @@
# Changelog / 更新日志
All notable changes are documented here. 本文件记录所有重要变更。
中英对照English first, 中文在后).
## [Unreleased]
### Added / 新增
- **Confirmation prompt before deleting a remote file (#28).** SFTP delete is
irreversible (there is no trash), so the context-menu *Delete* now asks for
confirmation — showing the full path — before removing anything; a misclick
no longer silently destroys a file.
**删除远程文件前先确认 (#28)。** SFTP 删除不可撤销(没有回收站),右键菜单的
「删除」现在会先弹出确认框(显示完整路径)再执行,误点不会再悄悄删掉文件。
- **Serial port sessions (#14, #17).** New session type for connecting to
switches, routers and embedded devices over a serial console. Pick
**Serial** in the session dialog and set the port (`COM3`, `/dev/ttyUSB0`),
baud rate, data/stop bits, parity and flow control. The serial line reuses
the full terminal pipeline (output, input, scrollback, copy/paste); SFTP and
the resource monitor are not applicable and are hidden.
**串口会话 (#14, #17)。** 新增串口会话类型,用于通过串口控制台连接交换机、
路由器和嵌入式设备。在会话对话框选择 **串口**,填写串口号(`COM3`
`/dev/ttyUSB0`)、波特率、数据/停止位、校验位和流控。串口复用完整的终端管线
(输出、输入、回滚、复制粘贴);SFTP 和资源监控不适用,已隐藏。
- **Telnet sessions (#17).** New session type for legacy gear that only speaks
Telnet. Handles RFC 854 option negotiation (suppress-go-ahead / echo /
window-size), strips IAC sequences from the stream, and tunnels through the
same SOCKS5 / HTTP proxy as SSH when configured.
**Telnet 会话 (#17)。** 新增 Telnet 会话类型,用于只支持 Telnet 的老旧设备。
处理 RFC 854 选项协商(抑制 Go-Ahead / 回显 / 窗口大小),从数据流中剥离 IAC
序列,并可经与 SSH 相同的 SOCKS5 / HTTP 代理隧道连接。
### Performance / 性能
- **Pipelined SFTP upload (#16).** Uploads now keep ~32 WRITE requests in flight
on a dedicated SFTP channel instead of writing one chunk and waiting for each
ack, hiding the round-trip latency that made transfers ~15x slower than `scp`.
Out-of-order completion is safe (every chunk carries its absolute offset).
**SFTP 上传流水线化 (#16)。** 上传改为在专用 SFTP 通道上保持约 32 个 WRITE 请求
并发在途,而不是写一块等一块的 ack,消除了让传输比 `scp` 慢约 15 倍的往返延迟。
乱序完成也安全(每块都带绝对偏移)。
### Fixed / 修复
- **Dragging the SFTP panel up no longer clears terminal output (#18).** vt100's
shrink truncated the grid from the bottom, dropping the most recent output;
before shrinking we now save the top rows to scrollback and scroll so the
bottom (recent) rows stay visible. Two follow-ups: (1) the shrink now only
scrolls off as many rows as needed to keep the cursor visible, so rapid
up/down dragging on a not-yet-full screen no longer pushes the prompt into
scrollback and strands the cursor at the top (also reported as #24); (2) drag-selection is now stored
in absolute scrollback coordinates, so selecting from the top of the history
down through several screens copies every line instead of losing everything
above the final window when the view auto-scrolls.
**上拉 SFTP 面板不再清空终端输出 (#18)。** vt100 缩小时从底部截断,丢掉最近输出;
现在缩小前把顶部行存入回滚区并滚动,使底部(最近)行保持可见。两处后续修复:
(1) 缩小时只滚走"保持光标可见所需"的行数,疯狂上下拖动未填满的屏幕时不再把
提示符推进回滚区、光标卡在顶部;(2) 拖选改用绝对回滚坐标存储,从历史顶部往下
跨多屏选择时能复制到每一行,而不是在视图自动滚动后丢掉最后一屏以上的内容。
### Security / 安全
- **Harden the remote resource monitor against a hostile server (#27).** The
monitor runs a small loop over an SSH exec channel. It now (1) resets `PATH`
to the standard system dirs so a server with a hijacked `PATH`/`BASH_ENV`
can't shadow `awk`/`cat`/`df`/`sleep`; (2) caps the reassembly buffer at 1 MiB
so a server that streams data without the sync marker can't exhaust memory;
and (3) parses `/proc` and `df` output with saturating arithmetic and a
64-row cap per sample, so crafted huge values or a flood of fake interfaces
can't overflow-panic or swamp the sidebar.
**加固远程资源监控以防恶意服务器 (#27)。** 监控通过 SSH exec 通道跑一个小循环。
现在:(1) 重置 `PATH` 为标准系统目录,使被劫持 `PATH`/`BASH_ENV` 的服务器无法
替换 `awk`/`cat`/`df`/`sleep`;(2) 重组缓冲上限 1 MiB,防止只发数据不发同步标记
的服务器耗尽内存;(3) 解析 `/proc``df` 输出改用饱和运算并对每次采样限 64 行,
使构造的超大数值或伪造网卡洪流无法触发溢出 panic 或拖垮侧栏。
- **Sanitize remote file names before saving downloads (#26).** SFTP downloads
built the local path straight from the server-supplied name, so a malicious
server could use path separators, shell-special characters or a Windows
reserved device name (`CON`, `NUL`, `COM1`…) to write outside the chosen
folder or hit a device. Downloads now run the name through `sanitize_filename`
(already used by the open/edit flow), which also gained reserved-device-name
and leading-whitespace handling.
**保存下载前清洗远程文件名 (#26)。** SFTP 下载直接用服务器给的文件名拼本地路径,
恶意服务器可借路径分隔符、shell 特殊字符或 Windows 保留设备名(`CON``NUL`
`COM1`…)写到目标目录之外或命中设备。现在下载会先经 `sanitize_filename`
(查看/编辑流程已在用)清洗,并新增了保留设备名与前导空白的处理。
- **Stop logging raw keystroke bytes (#15).** Debug logs recorded the hex of SSH
input, which could include passwords; now they record only the byte length.
A follow-up found two more leak sites in the key handler: `send_key` logged
the raw key string (`key={:?}`) at debug level, and the `[KEY_DIAG]` IME
diagnostic logged each Shift-typed key's code point at **info** level (no
`RUST_LOG` needed) — both could expose password characters. They now go
through a `redact_key` helper that reveals only C0/C1 control codes (what the
IME diagnostics actually need) and masks every printable character.
**不再记录原始按键字节 (#15)。** debug 日志原本记录 SSH 输入的十六进制(可能含
密码),现在只记录字节长度。后续又发现按键处理里还有两处泄露:`send_key`
debug 级打印按键原文(`key={:?}`),`[KEY_DIAG]` IME 诊断更是以 **info 级**
(无需 `RUST_LOG`)打印每个带 Shift 按键的码位——都可能暴露密码字符。现在统一
`redact_key` 处理,只保留 C0/C1 控制码(IME 诊断真正需要的),可打印字符一律掩码。
## [0.2.3] - 2026-06-05
### Added / 新增
- **Proxy support for SSH / SFTP (#7).** Connections can tunnel through a
**SOCKS5** (`socks5://`) or **HTTP CONNECT** (`http://`) proxy, with optional
`user:pass@` credentials. Set it per session in the dialog, or leave it blank
to use the `$ALL_PROXY` environment variable; empty = direct.
**SSH / SFTP 代理支持 (#7)。** 连接可经 **SOCKS5**(`socks5://`)或
**HTTP CONNECT**(`http://`)代理(支持 `user:pass@` 认证)。会话对话框里按需
填写,留空则用 `$ALL_PROXY` 环境变量,再空则直连。
- **Import hosts from `~/.ssh/config` (#1).** The "Import ~/.ssh/config" action
(in the settings menu) parses the standard SSH config (`Host` / `HostName` /
`User` / `Port` / `IdentityFile`, wildcard `Host *` blocks skipped) and adds
each host as a session, skipping duplicates. Hosts with an `IdentityFile`
default to key auth.
**从 `~/.ssh/config` 导入主机 (#1)。** 设置菜单里的「导入 ~/.ssh/config」解析
标准 SSH 配置(`Host` / `HostName` / `User` / `Port` / `IdentityFile`,跳过
`Host *` 通配块),将每个主机加为会话并跳过重复;带 `IdentityFile` 的默认用密钥。
- **GitHub Actions release workflow** building native binaries for Windows /
Linux / macOS (arm64 + x86_64) on each `v*` tag.
**GitHub Actions 发布工作流**,每个 `v*` 标签自动构建 Windows / Linux /
macOS(arm64 + x86_64)三平台二进制。
### Fixed / 修复
- The full-width `` before "New session" rendered as a tofu box in English;
switched to an ASCII `+`.
英文下「New session」前的全角 `` 显示为豆腐块,改用 ASCII `+`
- `install-linux.sh` now auto-detects the `meatshell` binary sitting next to it
in a release package, so it works with no arguments (it previously defaulted to
the source-tree `./target/release` path and failed for end users).
`install-linux.sh` 现在自动识别发布包里同目录的 `meatshell`,无需传参即可使用
(之前默认指向源码树的 `./target/release`,普通用户直接跑会报错)。
## [0.2.2] - 2026-06-05
### Security / 安全
- **Fix Windows command injection (#12)** — `open_with_os` no longer shells out
via `cmd /C start`; it calls `ShellExecuteW` directly so a malicious remote
file name (e.g. `foo&calc.exe`) can't inject commands. Added `sanitize_filename`
as defence-in-depth.
**修复 Windows 命令注入 (#12)** —— 打开文件不再经 `cmd /C start`,改用
`ShellExecuteW` 直接打开,恶意远程文件名(如 `foo&calc.exe`)无法注入命令;
并新增 `sanitize_filename` 清洗作为纵深防御。
- **Stop echoing the saved password when editing a session (#10)** — the field
is left blank with a "leave blank to keep" hint; an empty field on save keeps
the existing password.
**编辑会话时不再回显已保存密码 (#10)** —— 密码框留空并提示「留空则不修改」,
保存时为空则保留原密码。
- **Zero passwords in memory on drop (#8)** — passwords now use a `Secret` type
(`zeroize`) that wipes its heap buffer on drop and redacts itself in logs; the
on-disk JSON format is unchanged.
**密码内存清零 (#8)** —— 密码改用 `Secret` 类型(`zeroize`Drop 时清零堆
内存、日志中脱敏;磁盘 JSON 格式不变。
### Added / 新增
- **Internationalization — Chinese / English with runtime switching (#9).**
Static UI uses Slint `@tr` + bundled `.po`; dynamic Rust strings use a `t()`
helper. Switch via the gear menu; the choice is persisted and the default
follows the system locale.
**国际化 —— 中 / 英双语,运行时实时切换 (#9)。** 静态界面用 Slint `@tr` +
bundled `.po`Rust 动态文本用 `t()`。设置菜单里切换,选择会持久化,首次启动
跟随系统语言。
- **Private-key file picker** in the session dialog, plus `.pub` fallback (auto
strips the suffix to load the matching private key) and uniform `/` path
separators across platforms.
**会话弹窗的私钥文件选择器**,并支持 `.pub` 容错(自动去后缀加载对应私钥)、
路径分隔符统一为 `/`
- **Linux desktop integration** — `assets/meatshell.desktop` + `install-linux.sh`
and an `xdg_app_id` so the GNOME/Ubuntu dock shows the app icon on Wayland.
**Linux 桌面集成** —— `assets/meatshell.desktop` + `install-linux.sh`,并设置
`xdg_app_id`,使 Wayland 下 GNOME/Ubuntu 任务栏显示应用图标。
- **Screenshots in the README** (`docs/screenshots/`, sensitive info redacted).
**README 增加截图**`docs/screenshots/`,敏感信息已打码)。
[0.2.2]: https://github.com/jeff141/meatshell/releases/tag/v0.2.2

66
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,66 @@
# 参与 meatshell / Contributing to meatshell
> [English version below](#english)
## 中文
感谢你对 meatshell 的关注!🙏
meatshell 目前是一个**个人实验性项目**。它的设计、取舍和代码风格都由我一个人把控,目的是保持产品方向的一致性,做一个**轻量、专注的 FinalShell 风格 SSH 客户端**——不做成大而全的工具。
### 关于 Pull Request
为了保持这份一致性,**本项目暂不接受外部 Pull Request**。
这不是因为你的代码不够好,而是:
- 我希望每一行代码都经过自己的理解和取舍,避免项目逐渐偏离最初的设计意图;
- 维护一个能完全掌控的小项目,对我个人而言比快速扩张更重要。
如果你已经提交了 PR我可能会在阅读后礼貌关闭但**你的想法我都会认真看**——很多改进我会用自己的方式重新实现。
### 欢迎你这样参与
- 🐛 **报告 Bug**:在 [Issues](https://github.com/jeff141/meatshell/issues) 里描述复现步骤、系统环境和期望行为。
- 💡 **提出建议**:功能想法、交互改进、设计参考都欢迎在 Issue 区讨论。
-**Star / Fork**:如果你喜欢它,欢迎 fork 出自己的版本自由折腾。
清晰的 Issue 对我的帮助,往往不亚于一个 PR。再次感谢
---
<a name="english"></a>
## English
Thanks for your interest in meatshell! 🙏
meatshell is currently a **personal, experimental project**. Its design,
trade-offs, and code style are all maintained by a single person, on purpose —
the goal is a **lightweight, focused, FinalShell-style SSH client**, not an
all-in-one tool.
### About Pull Requests
To keep that consistency, **this project does not accept external Pull
Requests at this time.**
It's not that your code isn't good enough — rather:
- I want to personally understand and own every line of code, so the project
doesn't gradually drift away from its original intent;
- For me, keeping a small project I fully control matters more than growing fast.
If you've already opened a PR, I may close it politely after reading, but
**I do read every idea** — and I'll often re-implement worthwhile improvements
in my own way.
### How you're very welcome to help
- 🐛 **Report bugs**: open an [Issue](https://github.com/jeff141/meatshell/issues)
with repro steps, your OS/environment, and expected behavior.
- 💡 **Suggest ideas**: feature requests, UX improvements, and design references
are all welcome in the issue tracker.
-**Star / Fork**: if you like it, feel free to fork your own version and
hack away.
A clear issue often helps me as much as a PR would. Thank you!

77
Cargo.toml Normal file
View File

@@ -0,0 +1,77 @@
[package]
name = "meatshell"
version = "0.2.7"
edition = "2021"
authors = ["meatshell contributors"]
description = "A lightweight FinalShell-style SSH/terminal client written in Rust + Slint"
license = "MIT OR Apache-2.0"
repository = "https://github.com/your/meatshell"
readme = "README.md"
rust-version = "1.75"
[dependencies]
slint = { version = "1.8", features = ["compat-1-2"] }
# Direct access to the winit window/events (OS file-drop for drag-and-drop upload).
i-slint-backend-winit = "1.16"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
thiserror = "1"
directories = "5"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "io-util", "net", "time", "fs"] }
russh = "0.49"
russh-keys = "0.49"
ssh-key = "0.6"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
rand = "0.8"
async-trait = "0.1"
futures = "0.3"
vt100 = "0.15"
unicode-width = "0.2"
russh-sftp = "2"
rfd = "0.15"
arboard = "3"
# Zeroes password buffers on drop so plaintext credentials don't linger in
# freed heap memory (core dumps / debuggers / /proc/pid/mem).
zeroize = "1"
# SOCKS5 proxy tunnelling for SSH/SFTP in network-restricted environments.
# (HTTP CONNECT proxies are handled in src/proxy.rs without an extra crate.)
tokio-socks = "0.5"
base64 = "0.22"
# Cross-platform serial port access for the Serial session type (#14, #17).
# Opens COM3 / /dev/ttyUSB0 etc.; on Linux the default `libudev` feature is
# used for port enumeration (CI installs libudev-dev).
serialport = "4"
# ChaCha20-Poly1305 AEAD for encrypting passwords at rest in sessions.json.
# rand (OsRng) and base64 are already pulled in by other deps above.
chacha20poly1305 = "0.10"
# Detect whether the OS is running in dark or light mode.
# Used on startup to honour the system preference before the user overrides it.
dark-light = "1"
# Runtime background image decoding/blur. Used for png/jpg/jpeg/webp user
# backgrounds before handing pixels to Slint.
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
# Decode the embedded PNG icon to set the window icon at runtime (Linux only).
# On Windows the icon comes from the .ico embedded by winresource at link time;
# on macOS it comes from the app bundle — neither needs runtime decoding.
[build-dependencies]
slint-build = "1.8"
# Embeds assets/meatshell.ico into the .exe so Explorer / taskbar / shortcuts
# show the app icon. Kept as an unconditional build-dependency because build.rs
# runs on the host; Linux -> Windows cross builds still need this crate.
winresource = "0.1"
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = "symbols"
panic = "unwind"
[profile.dev]
opt-level = 1

111
Dockerfile.Miu_v1 Normal file
View File

@@ -0,0 +1,111 @@
# Gitea act runner image for meatshell.
#
# This is still a Linux container. It can build the Windows GNU target
# (`x86_64-pc-windows-gnu`) with MinGW, and can run the resulting .exe through
# Wine for simple smoke tests. It is not a replacement for a real Windows/MSVC
# runner (`x86_64-pc-windows-msvc`).
FROM ghcr.io/catthehacker/ubuntu:full-latest
USER root
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
ENV DEBIAN_FRONTEND=noninteractive
# Use Tsinghua Ubuntu mirrors, matching the base image codename instead of
# hard-coding noble.
RUN . /etc/os-release \
&& { \
echo "deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ ${VERSION_CODENAME} main restricted universe multiverse"; \
echo "deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ ${VERSION_CODENAME}-updates main restricted universe multiverse"; \
echo "deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ ${VERSION_CODENAME}-backports main restricted universe multiverse"; \
echo "deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ ${VERSION_CODENAME}-security main restricted universe multiverse"; \
} > /etc/apt/sources.list
# Build tools:
# - Linux GUI/dev libs match the official release workflow's Linux build deps.
# - MinGW provides the Windows GNU linker, archiver and windres.
# - Wine + Xvfb let cargo run/test Windows binaries via a virtual display.
RUN dpkg --add-architecture i386 \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
git \
build-essential \
pkg-config \
cmake \
clang \
lld \
libfontconfig1-dev \
libfreetype6-dev \
libxcb1-dev \
libxcb-render0-dev \
libxcb-shape0-dev \
libxcb-xfixes0-dev \
libxkbcommon-dev \
libxkbcommon-x11-dev \
libwayland-dev \
libgl1-mesa-dev \
libegl1-mesa-dev \
libgtk-3-dev \
libudev-dev \
mingw-w64 \
gcc-mingw-w64-x86-64 \
g++-mingw-w64-x86-64 \
binutils-mingw-w64-x86-64 \
xvfb \
x11-utils \
x11-xserver-utils \
wine64 \
wine32 \
wine \
wine32:i386 \
fonts-wine \
p7zip-full \
zip \
unzip \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Rust toolchain. Use rustup so the Windows target can be installed cleanly.
ENV RUSTUP_DIST_SERVER=https://mirrors.tuna.tsinghua.edu.cn/rustup
ENV RUSTUP_UPDATE_ROOT=https://mirrors.tuna.tsinghua.edu.cn/rustup/rustup
ENV RUSTUP_HOME=/root/.rustup
ENV CARGO_HOME=/root/.cargo
ENV PATH=/root/.cargo/bin:${PATH}
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
| sh -s -- -y --profile minimal --default-toolchain stable \
&& rustup target add x86_64-pc-windows-gnu x86_64-unknown-linux-gnu \
&& rustc --version \
&& cargo --version
# Cross-compilation configuration for cargo and build scripts.
ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc
ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_AR=x86_64-w64-mingw32-ar
ENV CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc
ENV CXX_x86_64_pc_windows_gnu=x86_64-w64-mingw32-g++
ENV AR_x86_64_pc_windows_gnu=x86_64-w64-mingw32-ar
ENV WINDRES=x86_64-w64-mingw32-windres
ENV PKG_CONFIG_ALLOW_CROSS=1
# Wine runtime setup for optional smoke tests.
ENV DISPLAY=:99
ENV WINEDLLOVERRIDES=winemenubuilder.exe=d
ENV WINEDEBUG=-all
ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_RUNNER=/usr/local/bin/wine-xvfb
RUN wine --version \
&& xvfb-run -a wineboot -u \
&& printf '%s\n' '#!/usr/bin/env bash' 'exec xvfb-run -a wine "$@"' \
> /usr/local/bin/wine-xvfb \
&& chmod +x /usr/local/bin/wine-xvfb \
&& printf '%s\n' \
'#!/usr/bin/env bash' \
'set -euo pipefail' \
'cargo build --release --target x86_64-pc-windows-gnu "$@"' \
> /usr/local/bin/build-win-gnu \
&& chmod +x /usr/local/bin/build-win-gnu
WORKDIR /workspace

147
README.en.md Normal file
View File

@@ -0,0 +1,147 @@
# meatshell
[简体中文](./README.md) | **English**
A lightweight, low-memory SSH / terminal client inspired by FinalShell, but
written entirely in **Rust + [Slint](https://slint.dev)**. The goal is to keep
FinalShell's core experience (resource-monitor sidebar, session management,
tabbed terminals) while cutting memory use from the 400 MB+ of a JVM app down to
the tens-of-MB range of a native binary.
## Screenshots
<p align="center">
<img src="docs/screenshots/01-welcome-en.png" alt="Welcome / session management" width="800"><br>
<em>Welcome page: session management + local resource monitor sidebar</em>
</p>
<p align="center">
<img src="docs/screenshots/02-terminal-btop.png" alt="Terminal + SFTP" width="800"><br>
<em>Tabbed terminal (full-screen btop) + SFTP file browser + remote resource monitoring</em>
</p>
## Download & install
Every pushed `v*` tag triggers a Gitea Actions build that produces the Windows
GNU package and publishes it on the
[Releases](https://git.miuoo.qzz.io/Miu/meatshell/releases) page.
### Windows
Download `meatshell-*-windows-x86_64.zip`, unzip, and run `meatshell.exe`.
At runtime, `meatshell-debug.log` is written next to `meatshell.exe`; check it first for SSH connection crashes or unexpected exits.
### Linux
```bash
tar -xzf meatshell-*-linux-x86_64.tar.gz
cd meatshell-*-linux-x86_64
./meatshell # run it directly
# Optional: install the app icon + launcher entry (shows the icon in the dock /
# app list — no argument needed, it finds the binary next to the script)
chmod +x install-linux.sh && ./install-linux.sh
```
> Requires glibc ≥ 2.35 (Ubuntu 22.04+ / Debian 12+). On Wayland you may need to
> log out/in once after installing the icon.
### macOS
```bash
tar -xzf meatshell-*-macos-*.tar.gz # aarch64 = Apple Silicon, x86_64 = Intel
xattr -dr com.apple.quarantine meatshell # clear the "unsigned app" Gatekeeper flag
./meatshell
```
> To build from source, see [Running](#running) below.
## Roadmap
### v0.1
- [x] FinalShell-style dark theme UI
- [x] Local system monitor sidebar (CPU / memory / swap / network throughput, 1 Hz)
- [x] Tabs (welcome page + multiple terminal sessions)
- [x] Session management: create / edit / delete, persisted to local JSON
- Config location: `%APPDATA%/meatshell/sessions.json` (Windows)
/ `~/.config/meatshell/sessions.json` (Linux)
/ `~/Library/Application Support/meatshell/sessions.json` (macOS)
- [x] SSH connection scaffold (`russh`, pure Rust, password + private key)
- [x] Raw PTY input + VT/ANSI terminal view
### v0.2
- [x] Full VT/ANSI terminal emulation (`vt100`/xterm parser, full-screen TUI, scrollback, find, select-copy)
- [x] Remote host resource monitoring (run a remote collector script, like FinalShell)
- [x] SFTP file browser + drag-and-drop upload/download
- [x] Known-hosts (`known_hosts`) verification (TOFU into the app known_hosts, reject changed keys)
- [x] Store session passwords in the OS keychain (encrypted config fallback when unavailable)
### v0.3+
- [x] Split panes for tabbed terminals
- [x] Session groups / folders
- [x] Theme switching (light / follow system)
- [x] Command history & snippet management
## Tech stack
| Module | Choice |
| ------------- | ----------------------------------------------------------------- |
| UI | [Slint](https://slint.dev) (compiled pure Rust, no GC) |
| Async runtime | [`tokio`](https://tokio.rs) |
| SSH protocol | [`russh`](https://crates.io/crates/russh) (no libssh dependency) |
| System metrics| [`sysinfo`](https://crates.io/crates/sysinfo) |
| Serialization | `serde` + `serde_json` |
| Logging | `tracing` + `tracing-subscriber` |
## Running
```bash
cargo run --release
```
On first launch an empty session store is created at
`%APPDATA%/meatshell/sessions.json`. Click **" New Session"** in the top-right
to add your first server.
## Project layout
```
meatshell/
├── Cargo.toml
├── build.rs # Slint compiler entry point
├── ui/
│ ├── app.slint # top-level window
│ ├── theme.slint # design tokens
│ ├── widgets.slint # reusable buttons / inputs / sparkline
│ ├── sidebar.slint # left-hand system monitor panel
│ ├── tabs.slint # top tab bar
│ ├── welcome.slint # welcome page / quick connect
│ ├── session_dialog.slint # new / edit session dialog
│ └── terminal_view.slint # VT/ANSI terminal view + command bar / SFTP panel
└── src/
├── main.rs
├── app.rs # UI ↔ backend bridge
├── config.rs # session JSON persistence
├── keychain.rs # OS keychain password storage + encrypted fallback
├── known_hosts.rs # OpenSSH known_hosts verification
├── system.rs # CPU / memory / network sampling
├── ssh.rs # SSH session worker
└── sftp.rs # SFTP browser / transfer worker
```
## Development notes
- Slint widgets use a strict layout DSL; after editing a `.slint` file,
`cargo check` is the fastest feedback loop.
- The application event loop is single-threaded (required by Slint); all
cross-thread UI updates go through `slint::invoke_from_event_loop` callbacks.
- `known_hosts` uses trust-on-first-use (TOFU): first connections are written
to meatshell's own `known_hosts`; later server-key changes are rejected.
- Session passwords prefer the OS keychain; when no keychain backend is
available, meatshell falls back to local ChaCha20-Poly1305 encrypted config.
## License
Dual-licensed under MIT OR Apache-2.0.

142
README.md Normal file
View File

@@ -0,0 +1,142 @@
# meatshell
**简体中文** | [English](./README.en.md)
一个轻量级、低内存占用的 SSH / 终端客户端,灵感来自 FinalShell但完全由
**Rust + [Slint](https://slint.dev)** 实现。目标是保留 FinalShell 的核心体验
(资源监控侧栏、会话管理、多标签页终端)的同时,把内存占用从 400 MB+ 的
JVM 压到几十 MB 原生级别。
## 截图
<p align="center">
<img src="docs/screenshots/01-welcome.png" alt="欢迎页 / 会话管理" width="800"><br>
<em>欢迎页:会话管理 + 左侧本机资源监控</em>
</p>
<p align="center">
<img src="docs/screenshots/02-terminal-btop.png" alt="终端 + SFTP" width="800"><br>
<em>多标签页终端btop 全屏渲染)+ 底部 SFTP 文件浏览 + 远端资源监控</em>
</p>
## 下载与安装
每次推送 `v*` 标签Gitea Actions 会自动构建 Windows GNU 版本并发布到
[Releases](https://git.miuoo.qzz.io/Miu/meatshell/releases) 页面。
### Windows
下载 `meatshell-*-windows-x86_64.zip`,解压后双击 `meatshell.exe`
运行时会在 `meatshell.exe` 同目录生成 `meatshell-debug.log`SSH 连接闪退或异常时请优先查看这个文件。
### Linux
```bash
tar -xzf meatshell-*-linux-x86_64.tar.gz
cd meatshell-*-linux-x86_64
./meatshell # 直接运行
# 可选:装应用图标 + 启动器入口Dock / 应用列表里显示图标,无需传参)
chmod +x install-linux.sh && ./install-linux.sh
```
> 需要 glibc ≥ 2.35Ubuntu 22.04+ / Debian 12+。Wayland 下首次装完图标可能要注销重登一次。
### macOS
```bash
tar -xzf meatshell-*-macos-*.tar.gz # aarch64 = Apple 芯片x86_64 = Intel
xattr -dr com.apple.quarantine meatshell # 去掉「未签名应用」的 Gatekeeper 拦截
./meatshell
```
> 从源码构建见下方 [运行](#运行)。
## 路线图
### v0.1
- [x] FinalShell 风格深色主题 UI
- [x] 左侧本机系统监控CPU / 内存 / 交换 / 网络吞吐1 Hz
- [x] 多标签页(欢迎页 + 多个终端会话)
- [x] 会话管理:新建 / 编辑 / 删除,本地 JSON 持久化
- 配置位置:`%APPDATA%/meatshell/sessions.json`Windows
/ `~/.config/meatshell/sessions.json`Linux
/ `~/Library/Application Support/meatshell/sessions.json`macOS
- [x] SSH 连接骨架(`russh`,纯 Rust 实现,支持密码 + 私钥)
- [x] 原始 PTY 输入 + VT/ANSI 终端视图
### v0.2
- [x] 完整 VT/ANSI 终端模拟(`vt100`/xterm 解析,支持全屏 TUI、滚动缓冲、查找、选择复制
- [x] 远端主机资源监控(与 FinalShell 一样执行远端脚本收集)
- [x] SFTP 文件浏览 + 拖拽上传/下载
- [x] 已知主机 (known_hosts) 校验(首次信任写入本应用 known_hosts密钥变化拒绝连接
- [x] 会话密码使用 OS 钥匙串存储(不可用时使用加密配置回退)
### v0.3+
- [x] 多标签页终端分屏
- [x] 会话分组 / 文件夹
- [x] 主题切换(浅色 / 跟随系统)
- [x] 命令历史与片段管理
## 技术栈
| 模块 | 选型 |
| ------------- | ----------------------------------------------------------------- |
| UI | [Slint](https://slint.dev)(纯 Rust 编译,无 GC |
| 异步运行时 | [`tokio`](https://tokio.rs) |
| SSH 协议 | [`russh`](https://crates.io/crates/russh)(无 libssh 依赖) |
| 系统指标 | [`sysinfo`](https://crates.io/crates/sysinfo) |
| 序列化 | `serde` + `serde_json` |
| 日志 | `tracing` + `tracing-subscriber` |
## 运行
```bash
cargo run --release
```
首次启动会在 `%APPDATA%/meatshell/sessions.json` 建立空的会话库。点击右上
**“+ 新建会话”** 添加第一台服务器。
## 项目布局
```
meatshell/
├── Cargo.toml
├── build.rs # Slint 编译器入口
├── ui/
│ ├── app.slint # 顶层窗口
│ ├── theme.slint # 设计 tokens
│ ├── widgets.slint # 可复用按钮 / 输入框 / sparkline
│ ├── sidebar.slint # 左侧系统监控面板
│ ├── tabs.slint # 顶部标签栏
│ ├── welcome.slint # 欢迎页 / 快速连接
│ ├── session_dialog.slint # 新建 / 编辑会话弹框
│ └── terminal_view.slint # VT/ANSI 终端视图 + 命令条 / SFTP 面板
└── src/
├── main.rs
├── app.rs # UI ↔ 后端桥接
├── config.rs # 会话 JSON 持久化
├── keychain.rs # 系统钥匙串密码存储 + 加密回退
├── known_hosts.rs # OpenSSH known_hosts 校验
├── system.rs # CPU / 内存 / 网络采样
├── ssh.rs # SSH 会话 worker
└── sftp.rs # SFTP 文件浏览 / 传输 worker
```
## 开发提示
- Slint 控件有非常严格的布局 DSL`.slint``cargo check` 是最快的
反馈方式。
- 应用事件循环是单线程Slint 要求),所有跨线程 UI 更新通过
`slint::invoke_from_event_loop` 回调。
- `known_hosts` 采用首次信任TOFU第一次连接会写入 meatshell 自己的
`known_hosts`,之后服务端密钥变化会拒绝连接。
- 会话密码优先存入系统钥匙串;若平台钥匙串不可用,会回退到本地
ChaCha20-Poly1305 加密配置。
## License
MIT OR Apache-2.0(双许可)。

44
assets/Info.plist Normal file
View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- The executable inside Contents/MacOS/ -->
<key>CFBundleExecutable</key>
<string>meatshell</string>
<!-- Points to Contents/Resources/meatshell.icns (no extension) -->
<key>CFBundleIconFile</key>
<string>meatshell</string>
<key>CFBundleIdentifier</key>
<string>dev.meatshell.meatshell</string>
<key>CFBundleName</key>
<string>meatshell</string>
<key>CFBundleDisplayName</key>
<string>meatshell</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<!-- __VERSION__ is substituted by sed in CI (e.g. "0.2.5") -->
<key>CFBundleShortVersionString</key>
<string>__VERSION__</string>
<key>CFBundleVersion</key>
<string>__VERSION__</string>
<!-- Opt-in to high-DPI Retina rendering -->
<key>NSHighResolutionCapable</key>
<true/>
<!-- macOS 11 Big Sur minimum (matches Apple Silicon support) -->
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2024 meatshell contributors. MIT OR Apache-2.0.</string>
</dict>
</plist>

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
assets/icon@512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

81
assets/install-linux.sh Normal file
View File

@@ -0,0 +1,81 @@
#!/usr/bin/env bash
#
# Install meatshell's icon + desktop entry on Linux so the GNOME/Ubuntu dock and
# the app launcher show the app icon.
#
# Why this is needed: the Windows build embeds the icon in the .exe, but on Linux
# the icon comes from a freedesktop ".desktop" entry plus an icon installed into
# the hicolor icon theme. On Wayland (Ubuntu's default) the shell matches a
# running window to its .desktop file via the window's app_id — meatshell sets
# that to "meatshell" (slint::set_xdg_app_id), and this script's StartupWMClass
# matches it.
#
# Usage:
# ./install-linux.sh [/path/to/meatshell-binary]
# You normally don't need an argument: when run from inside a release package
# (the `meatshell` binary sits next to this script) it is picked up automatically.
# In the source tree it falls back to ./target/release/meatshell.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Resolve the binary: explicit arg > sibling (release package) > source-tree build.
if [ -n "${1:-}" ]; then
BIN="$1"
elif [ -x "$SCRIPT_DIR/meatshell" ]; then
BIN="$SCRIPT_DIR/meatshell"
else
BIN="$SCRIPT_DIR/../target/release/meatshell"
fi
BIN="$(readlink -f "$BIN" 2>/dev/null || echo "$BIN")"
# Make sure the binary is executable (a downloaded tarball may have lost +x).
[ -f "$BIN" ] && chmod +x "$BIN" 2>/dev/null || true
if [ ! -x "$BIN" ]; then
echo "error: meatshell binary not found: $BIN" >&2
echo "Run this script from the extracted release folder (it sits next to the" >&2
echo "'meatshell' binary), or pass the binary path as an argument." >&2
exit 1
fi
ICON_SRC="$SCRIPT_DIR/icon@512.png"
ICON_DIR="$HOME/.local/share/icons/hicolor/512x512/apps"
APP_DIR="$HOME/.local/share/applications"
mkdir -p "$ICON_DIR" "$APP_DIR"
if [ -f "$ICON_SRC" ]; then
install -m644 "$ICON_SRC" "$ICON_DIR/meatshell.png"
else
echo "warning: icon not found ($ICON_SRC); the desktop entry will use a generic icon" >&2
fi
cat > "$APP_DIR/meatshell.desktop" <<EOF
[Desktop Entry]
Type=Application
Name=meatshell
GenericName=SSH Client
Comment=Lightweight Rust + Slint SSH/SFTP client
Comment[zh_CN]=轻量级 Rust + Slint SSH/SFTP 客户端
Exec=$BIN
Icon=meatshell
Terminal=false
Categories=Network;System;TerminalEmulator;Utility;
Keywords=ssh;sftp;terminal;shell;
StartupNotify=true
StartupWMClass=meatshell
EOF
chmod 644 "$APP_DIR/meatshell.desktop"
# Refresh the desktop + icon caches (best-effort; harmless if the tools are absent).
update-desktop-database "$APP_DIR" 2>/dev/null || true
gtk-update-icon-cache -f -t "$HOME/.local/share/icons/hicolor" 2>/dev/null || true
echo "Installed:"
echo " icon -> $ICON_DIR/meatshell.png"
echo " desktop -> $APP_DIR/meatshell.desktop"
echo " exec -> $BIN"
echo
echo "If the dock still shows the generic icon, log out/in (Wayland) or run"
echo "'killall -3 gnome-shell' (X11) to refresh the shell."

169
assets/make_icon.py Normal file
View File

@@ -0,0 +1,169 @@
# -*- coding: utf-8 -*-
"""Generate the meatshell app icon (braised pork-belly + terminal prompt).
Pure-Pillow, 4x supersampled for crisp anti-aliasing.
Outputs: icon.png (256), icon@512.png, meatshell.ico (multi-size)
"""
import math
from PIL import Image, ImageDraw, ImageFilter, ImageFont, ImageChops
SS = 4
BASE = 256
S = BASE * SS # working canvas 1024
OUT_DIR = "." # script is run from assets/
def lerp(a, b, t):
return int(round(a + (b - a) * t))
def vgradient(size, top, bot):
"""Vertical gradient RGB image."""
w, h = size
col = Image.new("RGB", (1, h))
px = col.load()
for y in range(h):
t = y / (h - 1)
px[0, y] = (lerp(top[0], bot[0], t),
lerp(top[1], bot[1], t),
lerp(top[2], bot[2], t))
return col.resize((w, h))
def wavy_band(x0, x1, y_top, y_bot, amp, phase, n=60):
"""Polygon points for a horizontal band with wavy top & bottom edges."""
pts = []
for i in range(n + 1):
x = x0 + (x1 - x0) * i / n
y = y_top + amp * math.sin(i / n * math.pi * 2.5 + phase)
pts.append((x, y))
for i in range(n, -1, -1):
x = x0 + (x1 - x0) * i / n
y = y_bot + amp * math.sin(i / n * math.pi * 2.5 + phase + 0.6)
pts.append((x, y))
return pts
img = Image.new("RGBA", (S, S), (0, 0, 0, 0))
# ---------------------------------------------------------------- tile (dark terminal)
tile_mask = Image.new("L", (S, S), 0)
ImageDraw.Draw(tile_mask).rounded_rectangle(
[0, 0, S - 1, S - 1], radius=int(S * 0.22), fill=255)
grad = vgradient((S, S), (37, 36, 46), (22, 21, 28)).convert("RGBA")
img.paste(grad, (0, 0), tile_mask)
# subtle top sheen on the tile
sheen = Image.new("RGBA", (S, S), (0, 0, 0, 0))
ImageDraw.Draw(sheen).ellipse(
[int(-S * 0.3), int(-S * 0.55), int(S * 1.3), int(S * 0.35)],
fill=(255, 255, 255, 22))
sheen.putalpha(ImageChops.multiply(sheen.getchannel("A"), tile_mask))
img = Image.alpha_composite(img, sheen)
# ---------------------------------------------------------------- meat block geometry
mx0, my0 = int(S * 0.165), int(S * 0.175)
mx1, my1 = int(S * 0.835), int(S * 0.825)
mw, mh = mx1 - mx0, my1 - my0
block_radius = int(mw * 0.17)
# drop shadow under the block
shadow = Image.new("RGBA", (S, S), (0, 0, 0, 0))
ImageDraw.Draw(shadow).rounded_rectangle(
[mx0, my0 + int(S * 0.02), mx1, my1 + int(S * 0.03)],
radius=block_radius, fill=(0, 0, 0, 150))
shadow = shadow.filter(ImageFilter.GaussianBlur(S * 0.025))
img = Image.alpha_composite(img, shadow)
# meat layers on their own layer, then clip to rounded rect
meat = Image.new("RGBA", (S, S), (0, 0, 0, 0))
md = ImageDraw.Draw(meat)
# base fill so wavy edges never leave gaps
md.rounded_rectangle([mx0, my0, mx1, my1], radius=block_radius,
fill=(178, 47, 38, 255))
amp = mh * 0.025
# (start_frac, end_frac, color) top -> bottom, 五花肉 cross-section
layers = [
(-0.02, 0.15, (94, 52, 32)), # skin / caramel glaze
(0.13, 0.31, (255, 224, 209)), # fat
(0.29, 0.52, (199, 60, 45)), # lean
(0.50, 0.66, (255, 216, 201)), # fat
(0.64, 1.04, (181, 48, 39)), # lean
]
for idx, (a, b, col) in enumerate(layers):
yt = my0 + mh * a
yb = my0 + mh * b
md.polygon(wavy_band(mx0 - 4, mx1 + 4, yt, yb, amp, idx * 1.7),
fill=col + (255,))
# marbling: faint warm streaks of fat running through the lean layers
for (ly, ph) in [(0.42, 0.0), (0.74, 1.1), (0.92, 2.0)]:
yc = my0 + mh * ly
pts = []
n = 60
for i in range(n + 1):
x = mx0 + mw * i / n
y = yc + mh * 0.010 * math.sin(i / n * math.pi * 3 + ph)
pts.append((x, y))
md.line(pts, fill=(255, 228, 214, 70), width=int(S * 0.007), joint="curve")
# glossy highlight on the glaze (top skin) — warm, not gray
md.ellipse([mx0 + mw * 0.12, my0 + mh * 0.012,
mx0 + mw * 0.60, my0 + mh * 0.085],
fill=(255, 240, 224, 90))
# clip meat to rounded-rect
meat_mask = Image.new("L", (S, S), 0)
ImageDraw.Draw(meat_mask).rounded_rectangle(
[mx0, my0, mx1, my1], radius=block_radius, fill=255)
meat.putalpha(ImageChops.multiply(meat.getchannel("A"), meat_mask))
img = Image.alpha_composite(img, meat)
# thin inner rim to define the block edge
rim = Image.new("RGBA", (S, S), (0, 0, 0, 0))
ImageDraw.Draw(rim).rounded_rectangle(
[mx0, my0, mx1, my1], radius=block_radius, outline=(40, 18, 12, 160),
width=int(S * 0.006))
img = Image.alpha_composite(img, rim)
# ---------------------------------------------------------------- terminal prompt >_
def load_font(size):
for path in (r"C:\Windows\Fonts\consolab.ttf",
r"C:\Windows\Fonts\consola.ttf",
r"C:\Windows\Fonts\lucon.ttf"):
try:
return ImageFont.truetype(path, size)
except OSError:
continue
return ImageFont.load_default()
prompt = Image.new("RGBA", (S, S), (0, 0, 0, 0))
pd = ImageDraw.Draw(prompt)
font = load_font(int(S * 0.30))
text = ">_"
bbox = pd.textbbox((0, 0), text, font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
tx = (S - tw) / 2 - bbox[0]
ty = (S - th) / 2 - bbox[1]
# dark shadow for legibility on the meat
pd.text((tx + S * 0.012, ty + S * 0.012), text, font=font, fill=(20, 8, 4, 180))
# bright terminal green
pd.text((tx, ty), text, font=font, fill=(126, 255, 122, 255))
prompt = prompt.filter(ImageFilter.GaussianBlur(1.2))
img = Image.alpha_composite(img, prompt)
# ---------------------------------------------------------------- export
img256 = img.resize((BASE, BASE), Image.LANCZOS)
img512 = img.resize((512, 512), Image.LANCZOS)
img256.save("icon.png")
img512.save("icon@512.png")
img.resize((512, 512), Image.LANCZOS).save("icon@1024_preview.png") # for review
img256.save("meatshell.ico", format="ICO",
sizes=[(256, 256), (128, 128), (64, 64),
(48, 48), (32, 32), (16, 16)])
print("OK: icon.png, icon@512.png, meatshell.ico written")

13
assets/meatshell.desktop Normal file
View File

@@ -0,0 +1,13 @@
[Desktop Entry]
Type=Application
Name=meatshell
GenericName=SSH Client
Comment=Lightweight Rust + Slint SSH/SFTP client
Comment[zh_CN]=轻量级 Rust + Slint SSH/SFTP 客户端
Exec=meatshell
Icon=meatshell
Terminal=false
Categories=Network;System;TerminalEmulator;Utility;
Keywords=ssh;sftp;terminal;shell;
StartupNotify=true
StartupWMClass=meatshell

BIN
assets/meatshell.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

30
build.rs Normal file
View File

@@ -0,0 +1,30 @@
fn main() {
// Bundle the gettext `.po` translations under `lang/` so the UI's `@tr(...)`
// strings can switch language at runtime via slint::select_bundled_translation.
// Source language is Chinese (the msgids); `lang/<lc>/LC_MESSAGES/meatshell.po`
// provides other locales. No per-component context, so msgids are the raw
// Chinese strings.
println!("cargo:rerun-if-changed=lang");
slint_build::compile_with_config(
"ui/app.slint",
slint_build::CompilerConfiguration::new()
.with_style("fluent".into())
.with_bundled_translations("lang")
.with_default_translation_context(slint_build::DefaultTranslationContext::None),
)
.expect("Slint build failed");
// Embed the application icon into the Windows executable so it shows up in
// Explorer, the taskbar and shortcuts. Build scripts run on the host, so a
// Linux -> Windows cross build must check the target OS via Cargo env vars
// instead of `#[cfg(windows)]`.
println!("cargo:rerun-if-env-changed=CARGO_CFG_TARGET_OS");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
println!("cargo:rerun-if-changed=assets/meatshell.ico");
let mut res = winresource::WindowsResource::new();
res.set_icon("assets/meatshell.ico");
if let Err(e) = res.compile() {
println!("cargo:warning=failed to embed Windows icon: {e}");
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

120
docs/ui-layout.md Normal file
View File

@@ -0,0 +1,120 @@
# UI layout boundaries
The UI should follow a FinalShell-style work layout: navigation on the left,
tabs and terminal workspace in the center, and operational tools as separate
panels. Keep one terminal per tab. Terminal split panes are intentionally not
part of the product surface because they couple session lifecycle, resize,
SFTP, resource sampling, and focus handling too tightly.
## Independent Components
### AppShell
Owns the top-level layout only:
- left navigation width
- main workspace area
- top-right global actions
- modal/popup mounting
It should not own terminal rendering, SFTP state, or session form details.
### ConnectionNavigator
Left-side session tree/list, similar to FinalShell's connection manager:
- session groups
- saved sessions
- quick connect entry points
- edit/delete/import actions
This should replace putting the full session list on the welcome page as the
primary workflow. The welcome page can remain a light empty state.
### WorkspaceTabs
Owns tab metadata and switching:
- welcome tab
- terminal tabs
- close/select/new-tab commands
It should not know about SFTP rows, terminal spans, or resource graphs.
### TerminalWorkspace
Owns the active terminal tab content area:
- one `TerminalView` per active terminal tab
- keyboard focus routing
- terminal resize forwarding
- terminal status line
No split panes. If multiple terminals are needed, open multiple tabs.
### TerminalToolbar
Commands around the terminal should be separate from terminal rendering:
- command input
- run/save snippet buttons
- snippet popup
- find toggle
- copy/paste/clear actions
This keeps terminal grid rendering focused on VT output and selection.
### FilePanel
SFTP should be an independent panel, not terminal internals:
- remote path
- remote tree
- file list
- upload/download/view/edit/delete
- loading/status text
It can be docked below or to the right of the terminal, but its data model
should remain separate from terminal spans and cursor state.
### ResourcePanel
Local/remote machine stats should be independent:
- connection status
- CPU/memory/swap
- network graphs
- disk usage
It may live in the left sidebar initially, but it should not be mixed with
session navigation logic.
### TransferManager
Global transfer history/progress:
- active transfers
- completed transfers
- failed transfers
- clear/open download folder actions
This remains global because downloads can outlive tab focus.
### SessionEditor
Session creation and editing should stay modal and isolated:
- SSH/Telnet/Serial fields
- auth fields
- key picker
- validation and save/cancel
It should not manipulate tab/terminal models directly.
## Current Refactor Priority
1. Remove terminal split panes and keep one terminal per tab.
2. Move session navigation out of the welcome screen into an independent left
component.
3. Split `TerminalView` into terminal grid, command toolbar, and SFTP/file panel.
4. Keep resource stats and transfer manager as independent global panels.

View File

@@ -0,0 +1,355 @@
msgid ""
msgstr ""
"Project-Id-Version: meatshell\n"
"Language: en\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
msgid "Not connected"
msgstr "Not connected"
msgid "Local resources"
msgstr "Local resources"
msgid "Server resources"
msgstr "Server resources"
msgid "Download settings"
msgstr "Download settings"
msgid "Save downloads to:"
msgstr "Save downloads to:"
msgid "(ask every time)"
msgstr "(ask every time)"
msgid "Choose folder"
msgstr "Choose folder"
msgid "Open folder"
msgstr "Open folder"
msgid "Transfers"
msgstr "Transfers"
msgid "Clear"
msgstr "Clear"
msgid "No transfers yet"
msgstr "No transfers yet"
msgid "About"
msgstr "About"
msgid "About meatshell"
msgstr "About meatshell"
msgid "A lightweight Rust + Slint SSH terminal client by 'lil meatball'."
msgstr "A lightweight Rust + Slint SSH terminal client by 'lil meatball'."
msgid "Open source · MIT / Apache-2.0"
msgstr "Open source · MIT / Apache-2.0"
msgid "Open-source libraries used"
msgstr "Open-source libraries used"
msgid "Edit session"
msgstr "Edit session"
msgid "New session"
msgstr "New session"
msgid "Name"
msgstr "Name"
msgid "e.g. production web-01"
msgstr "e.g. production web-01"
msgid "Host / IP"
msgstr "Host / IP"
msgid "Port"
msgstr "Port"
msgid "Username"
msgstr "Username"
msgid "Authentication"
msgstr "Authentication"
msgid "Password"
msgstr "Password"
msgid "Private key"
msgstr "Private key"
msgid "Leave blank to keep the current password"
msgstr "Leave blank to keep the current password"
msgid "Private key file"
msgstr "Private key file"
msgid "Browse…"
msgstr "Browse…"
msgid "Pick the private key itself (not the .pub public key)"
msgstr "Pick the private key itself (not the .pub public key)"
msgid "Cancel"
msgstr "Cancel"
msgid "Save"
msgstr "Save"
msgid "Create"
msgstr "Create"
msgid "SFTP not connected"
msgstr "SFTP not connected"
msgid "Upload"
msgstr "Upload"
msgid "Directory tree"
msgstr "Directory tree"
msgid "Size"
msgstr "Size"
msgid "Modified"
msgstr "Modified"
msgid "Loading..."
msgstr "Loading..."
msgid "Empty directory"
msgstr "Empty directory"
msgid "Download"
msgstr "Download"
msgid "View"
msgstr "View"
msgid "Edit"
msgstr "Edit"
msgid "Delete"
msgstr "Delete"
msgid "Status"
msgstr "Status"
msgid "Memory"
msgstr "Memory"
msgid "Swap"
msgstr "Swap"
msgid "Local"
msgstr "Local"
msgid "Path"
msgstr "Path"
msgid "Free/Total"
msgstr "Free/Total"
msgid "Copy"
msgstr "Copy"
msgid "Paste"
msgstr "Paste"
msgid "Clear buffer"
msgstr "Clear buffer"
msgid "Find"
msgstr "Find"
msgid "meatshell — a lightweight Rust + Slint SSH client by 'lil meatball'"
msgstr "meatshell — a lightweight Rust + Slint SSH client by 'lil meatball'"
msgid "Quick connect"
msgstr "Quick connect"
msgid "+ New session"
msgstr "+ New session"
msgid "No sessions yet"
msgstr "No sessions yet"
msgid "Use 'New session' (top-right) to add your first server"
msgstr "Use 'New session' (top-right) to add your first server"
msgid "Import ~/.ssh/config"
msgstr "Import ~/.ssh/config"
msgid "Proxy (optional)"
msgstr "Proxy (optional)"
msgid "Leave blank to use $ALL_PROXY or connect directly"
msgstr "Leave blank to use $ALL_PROXY or connect directly"
msgid "Connection type"
msgstr "Connection type"
msgid "Serial"
msgstr "Serial"
msgid "Serial port"
msgstr "Serial port"
msgid "Baud rate"
msgstr "Baud rate"
msgid "Data bits / Stop bits / Parity"
msgstr "Data bits / Stop bits / Parity"
msgid "Flow control"
msgstr "Flow control"
msgid "None"
msgstr "None"
msgid "Hardware"
msgstr "Hardware"
msgid "Software"
msgstr "Software"
msgid "Confirm delete"
msgstr "Confirm delete"
msgid "Delete this item? This cannot be undone."
msgstr "Delete this item? This cannot be undone."
msgid "Please confirm"
msgstr "Please confirm"
msgid "Confirm"
msgstr "Confirm"
msgid "Connection manager"
msgstr "Connection manager"
msgid "Use the plus button above to add SSH, Serial, or Telnet"
msgstr "Use the plus button above to add SSH, Serial, or Telnet"
msgid "File management"
msgstr "File management"
msgid "Command management"
msgstr "Command management"
msgid "Categories"
msgstr "Categories"
msgid "Custom commands"
msgstr "Custom commands"
msgid "No custom commands"
msgstr "No custom commands"
msgid "New"
msgstr "New"
msgid "New command"
msgstr "New command"
msgid "Command detail"
msgstr "Command detail"
msgid "New category"
msgstr "New category"
msgid "Rename category"
msgstr "Rename category"
msgid "Rename"
msgstr "Rename"
msgid "Run"
msgstr "Run"
msgid "Append CR"
msgstr "Append CR"
msgid "Parameters"
msgstr "Parameters"
msgid "Missing parameters"
msgstr "Missing parameters"
msgid "Move to category"
msgstr "Move to category"
msgid "Configuration"
msgstr "Configuration"
msgid "Maximum terminal windows"
msgstr "Maximum terminal windows"
msgid "Terminal font size"
msgstr "Terminal font size"
msgid "Folder"
msgstr "Folder"
msgid "New folder"
msgstr "New folder"
msgid "Folder name"
msgstr "Folder name"
msgid "Folder name is required"
msgstr "Folder name is required"
msgid "Category name"
msgstr "Category name"
msgid "Category name is required"
msgstr "Category name is required"
msgid "Auto"
msgstr "Auto"
msgid "File manager font size"
msgstr "File manager font size"
msgid "Background image"
msgstr "Background image"
msgid "No background image"
msgstr "No background image"
msgid "Background scope"
msgstr "Background scope"
msgid "Terminal"
msgstr "Terminal"
msgid "Fullscreen"
msgstr "Fullscreen"
msgid "Blur"
msgstr "Blur"
msgid "Configuration directory"
msgstr "Configuration directory"
msgid "Choose"
msgstr "Choose"
msgid "Debug identifiers"
msgstr "Debug identifiers"
msgid "On"
msgstr "On"
msgid "Off"
msgstr "Off"
msgid "Shows stable ids on UI parts for bug reports"
msgstr "Shows stable ids on UI parts for bug reports"

View File

@@ -0,0 +1,355 @@
msgid ""
msgstr ""
"Project-Id-Version: meatshell\n"
"Language: zh\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
msgid "Not connected"
msgstr "未连接"
msgid "Local resources"
msgstr "本机资源"
msgid "Server resources"
msgstr "服务器资源"
msgid "Download settings"
msgstr "下载设置"
msgid "Save downloads to:"
msgstr "下载保存到:"
msgid "(ask every time)"
msgstr "(每次下载时询问)"
msgid "Choose folder"
msgstr "选择文件夹"
msgid "Open folder"
msgstr "打开目录"
msgid "Transfers"
msgstr "传输记录"
msgid "Clear"
msgstr "清空"
msgid "No transfers yet"
msgstr "暂无传输记录"
msgid "About"
msgstr "关于"
msgid "About meatshell"
msgstr "关于 meatshell"
msgid "A lightweight Rust + Slint SSH terminal client by 'lil meatball'."
msgstr "一个由「一坨肉」开发的轻量 Rust + Slint SSH 终端客户端。"
msgid "Open source · MIT / Apache-2.0"
msgstr "开源项目 · 协议 MIT / Apache-2.0"
msgid "Open-source libraries used"
msgstr "使用的开源库"
msgid "Edit session"
msgstr "编辑会话"
msgid "New session"
msgstr "新建会话"
msgid "Name"
msgstr "名称"
msgid "e.g. production web-01"
msgstr "例如:生产环境 web-01"
msgid "Host / IP"
msgstr "主机 / IP"
msgid "Port"
msgstr "端口"
msgid "Username"
msgstr "用户名"
msgid "Authentication"
msgstr "认证方式"
msgid "Password"
msgstr "密码"
msgid "Private key"
msgstr "私钥"
msgid "Leave blank to keep the current password"
msgstr "留空则不修改密码"
msgid "Private key file"
msgstr "私钥文件"
msgid "Browse…"
msgstr "浏览…"
msgid "Pick the private key itself (not the .pub public key)"
msgstr "选择私钥本身(不是 .pub 公钥文件)"
msgid "Cancel"
msgstr "取消"
msgid "Save"
msgstr "保存"
msgid "Create"
msgstr "创建"
msgid "SFTP not connected"
msgstr "SFTP 未连接"
msgid "Upload"
msgstr "上传"
msgid "Directory tree"
msgstr "目录树"
msgid "Size"
msgstr "大小"
msgid "Modified"
msgstr "修改时间"
msgid "Loading..."
msgstr "加载中..."
msgid "Empty directory"
msgstr "目录为空"
msgid "Download"
msgstr "下载"
msgid "View"
msgstr "查看"
msgid "Edit"
msgstr "编辑"
msgid "Delete"
msgstr "删除"
msgid "Status"
msgstr "运行状态"
msgid "Memory"
msgstr "内存"
msgid "Swap"
msgstr "交换"
msgid "Local"
msgstr "本机"
msgid "Path"
msgstr "路径"
msgid "Free/Total"
msgstr "可用/大小"
msgid "Copy"
msgstr "复制"
msgid "Paste"
msgstr "粘贴"
msgid "Clear buffer"
msgstr "清空缓存"
msgid "Find"
msgstr "查找"
msgid "meatshell — a lightweight Rust + Slint SSH client by 'lil meatball'"
msgstr "meatshell 一个由「一坨肉」开发的轻量 Rust + Slint SSH 客户端"
msgid "Quick connect"
msgstr "快速连接"
msgid "+ New session"
msgstr "+ 新建会话"
msgid "No sessions yet"
msgstr "尚无会话"
msgid "Use 'New session' (top-right) to add your first server"
msgstr "点击右上角 “新建会话” 添加你的第一台服务器"
msgid "Import ~/.ssh/config"
msgstr "导入 ~/.ssh/config"
msgid "Proxy (optional)"
msgstr "代理(可选)"
msgid "Leave blank to use $ALL_PROXY or connect directly"
msgstr "留空则用 $ALL_PROXY 环境变量,或直接连接"
msgid "Connection type"
msgstr "连接类型"
msgid "Serial"
msgstr "串口"
msgid "Serial port"
msgstr "串口号"
msgid "Baud rate"
msgstr "波特率"
msgid "Data bits / Stop bits / Parity"
msgstr "数据位 / 停止位 / 校验位"
msgid "Flow control"
msgstr "流控"
msgid "None"
msgstr "无"
msgid "Hardware"
msgstr "硬件"
msgid "Software"
msgstr "软件"
msgid "Confirm delete"
msgstr "确认删除"
msgid "Delete this item? This cannot be undone."
msgstr "确定删除此项?此操作不可撤销。"
msgid "Please confirm"
msgstr "请确认"
msgid "Confirm"
msgstr "确认"
msgid "Connection manager"
msgstr "连接管理"
msgid "Use the plus button above to add SSH, Serial, or Telnet"
msgstr "点击上方加号添加 SSH、串口或 Telnet"
msgid "File management"
msgstr "文件管理"
msgid "Command management"
msgstr "命令管理"
msgid "Categories"
msgstr "分类"
msgid "Custom commands"
msgstr "自定义命令"
msgid "No custom commands"
msgstr "暂无自定义命令"
msgid "New"
msgstr "新建"
msgid "New command"
msgstr "新建命令"
msgid "Command detail"
msgstr "命令详情"
msgid "New category"
msgstr "新建分类"
msgid "Rename category"
msgstr "重命名分类"
msgid "Rename"
msgstr "重命名"
msgid "Run"
msgstr "运行"
msgid "Append CR"
msgstr "末尾添加 CR"
msgid "Parameters"
msgstr "参数"
msgid "Missing parameters"
msgstr "参数未填写"
msgid "Move to category"
msgstr "移动到分类"
msgid "Configuration"
msgstr "配置"
msgid "Maximum terminal windows"
msgstr "最大终端窗口数"
msgid "Terminal font size"
msgstr "终端字体大小"
msgid "Folder"
msgstr "文件夹"
msgid "New folder"
msgstr "新建文件夹"
msgid "Folder name"
msgstr "文件夹名称"
msgid "Folder name is required"
msgstr "文件夹名称不能为空"
msgid "Category name"
msgstr "分类名称"
msgid "Category name is required"
msgstr "分类名称不能为空"
msgid "Auto"
msgstr "自动"
msgid "File manager font size"
msgstr "文件管理字体大小"
msgid "Background image"
msgstr "背景图"
msgid "No background image"
msgstr "未设置背景图"
msgid "Background scope"
msgstr "背景范围"
msgid "Terminal"
msgstr "终端"
msgid "Fullscreen"
msgstr "全屏"
msgid "Blur"
msgstr "模糊"
msgid "Configuration directory"
msgstr "配置目录"
msgid "Choose"
msgstr "选择"
msgid "Debug identifiers"
msgstr "调试标识"
msgid "On"
msgstr "开启"
msgid "Off"
msgstr "关闭"
msgid "Shows stable ids on UI parts for bug reports"
msgstr "在界面部件上显示固定 ID便于反馈问题"

5125
src/app.rs Normal file

File diff suppressed because it is too large Load Diff

1267
src/config.rs Normal file

File diff suppressed because it is too large Load Diff

155
src/debug_log.rs Normal file
View File

@@ -0,0 +1,155 @@
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::PathBuf;
use std::time::SystemTime;
const LOG_NAME: &str = "meatshell-debug.log";
pub fn init() -> Option<PathBuf> {
let (file, path) = match open_log_file() {
Some(pair) => pair,
None => {
install_panic_hook(None);
init_stderr_tracing();
tracing::warn!("failed to create {LOG_NAME}; logging to stderr only");
return None;
}
};
install_panic_hook(Some(path.clone()));
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,meatshell=debug,winit=error"));
let log_file = file;
if tracing_subscriber::fmt()
.with_env_filter(filter)
.with_ansi(false)
.with_thread_ids(true)
.with_writer(move || log_file.try_clone().expect("clone debug log file"))
.try_init()
.is_err()
{
return Some(path);
}
tracing::info!(
version = env!("CARGO_PKG_VERSION"),
os = std::env::consts::OS,
arch = std::env::consts::ARCH,
"debug logging initialized"
);
if log_paths_enabled() {
tracing::debug!(log = %path.display(), "debug log path");
if let Ok(cwd) = std::env::current_dir() {
tracing::debug!(cwd = %cwd.display(), "current working directory");
}
if let Ok(exe) = std::env::current_exe() {
tracing::debug!(exe = %exe.display(), "current executable");
}
}
Some(path)
}
fn init_stderr_tracing() {
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,meatshell=debug,winit=error"));
let _ = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_ansi(false)
.with_thread_ids(true)
.try_init();
}
fn open_log_file() -> Option<(File, PathBuf)> {
let mut dirs = Vec::new();
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
dirs.push(dir.to_path_buf());
}
}
if let Ok(cwd) = std::env::current_dir() {
if !dirs.iter().any(|dir| dir == &cwd) {
dirs.push(cwd);
}
}
for dir in dirs {
let path = dir.join(LOG_NAME);
if let Ok(file) = OpenOptions::new().create(true).append(true).open(&path) {
return Some((file, path));
}
}
None
}
fn install_panic_hook(path: Option<PathBuf>) {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let payload = if let Some(message) = info.payload().downcast_ref::<&str>() {
*message
} else if let Some(message) = info.payload().downcast_ref::<String>() {
message.as_str()
} else {
"<non-string panic payload>"
};
let location = info
.location()
.map(|loc| format!("{}:{}:{}", short_file(loc.file()), loc.line(), loc.column()))
.unwrap_or_else(|| "<unknown>".to_string());
let backtrace = if env_flag("MEATSHELL_DEBUG_BACKTRACE") {
Some(std::backtrace::Backtrace::force_capture().to_string())
} else {
None
};
if let Some(path) = &path {
if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(
file,
"\n===== panic {:?} =====\nlocation: {}\nmessage: {}",
SystemTime::now(),
location,
payload
);
if let Some(backtrace) = &backtrace {
let _ = writeln!(file, "backtrace:\n{}", backtrace);
}
let _ = writeln!(file);
}
}
if let Some(backtrace) = &backtrace {
tracing::error!(
location = %location,
message = %payload,
backtrace = %backtrace,
"panic captured"
);
} else {
tracing::error!(
location = %location,
message = %payload,
"panic captured"
);
}
previous(info);
}));
}
pub(crate) fn log_paths_enabled() -> bool {
env_flag("MEATSHELL_DEBUG_PATHS")
}
fn env_flag(name: &str) -> bool {
matches!(
std::env::var(name).as_deref(),
Ok("1") | Ok("true") | Ok("TRUE") | Ok("yes") | Ok("YES") | Ok("on") | Ok("ON")
)
}
fn short_file(path: &str) -> &str {
path.rsplit(['/', '\\']).next().unwrap_or(path)
}

64
src/i18n.rs Normal file
View File

@@ -0,0 +1,64 @@
//! Tiny runtime internationalisation.
//!
//! Two cooperating mechanisms keep the whole UI translatable:
//!
//! * **Static `.slint` text** uses Slint's own `@tr("English")` plus bundled
//! `.po` translations. The source language (the msgids) is **English**; the
//! Chinese strings live in `lang/zh/LC_MESSAGES/meatshell.po`. Switching is
//! done with `slint::select_bundled_translation` (`"zh"` → Chinese, `""`/`"en"`
//! → the English source).
//!
//! * **Dynamic Rust text** (status lines, errors, transfer details that Rust
//! builds with `format!`) can't use `@tr`, so it uses [`t`] which returns the
//! Chinese or English variant based on the current language flag.
//!
//! [`set_language`] updates both at once so the two stay in sync.
use std::sync::atomic::{AtomicU8, Ordering};
const ZH: u8 = 0;
const EN: u8 = 1;
static LANG: AtomicU8 = AtomicU8::new(ZH);
/// Apply a language code (`"zh"` or `"en"`). Updates the Rust-side flag and
/// Slint's bundled-translation selection. Safe to call before the first
/// component exists for the flag; the Slint selection is a no-op error then and
/// should be re-applied once the window is created.
pub fn set_language(code: &str) {
let en = code.eq_ignore_ascii_case("en");
LANG.store(if en { EN } else { ZH }, Ordering::Relaxed);
apply_to_slint();
}
/// Re-apply the current language to Slint's bundled translations. Must run
/// after the first component is created (Slint requirement). We bundle BOTH an
/// `en` (identity) and a `zh` translation and select explicitly, because the
/// empty/`"en"` shortcut selects bundle index 0 — which would be `zh` when only
/// the Chinese bundle exists.
pub fn apply_to_slint() {
let lang = if is_en() { "en" } else { "zh" };
let _ = slint::select_bundled_translation(lang);
}
/// Current language code, for persisting to config (`"zh"` / `"en"`).
pub fn current_code() -> &'static str {
if is_en() {
"en"
} else {
"zh"
}
}
pub fn is_en() -> bool {
LANG.load(Ordering::Relaxed) == EN
}
/// Pick the variant for the current language: `zh` is Chinese, `en` is English.
pub fn t(zh: &'static str, en: &'static str) -> &'static str {
if is_en() {
en
} else {
zh
}
}

331
src/keychain.rs Normal file
View File

@@ -0,0 +1,331 @@
//! Best-effort OS keychain storage for session passwords.
//!
//! Windows and macOS use native system APIs. Linux uses `secret-tool` when
//! available (GNOME Keyring / Secret Service). Callers keep an encrypted config
//! fallback for machines without a usable keychain.
use anyhow::{anyhow, Context, Result};
const SERVICE: &str = "meatshell";
pub fn get_password(account: &str) -> Result<String> {
platform::get_password(account)
}
pub fn set_password(account: &str, password: &str) -> Result<()> {
platform::set_password(account, password)
}
#[cfg(windows)]
mod platform {
use super::*;
use std::ffi::c_void;
use std::slice;
const CRED_TYPE_GENERIC: u32 = 1;
const CRED_PERSIST_LOCAL_MACHINE: u32 = 2;
#[repr(C)]
struct FileTime {
low_date_time: u32,
high_date_time: u32,
}
#[repr(C)]
struct CredentialW {
flags: u32,
typ: u32,
target_name: *mut u16,
comment: *mut u16,
last_written: FileTime,
credential_blob_size: u32,
credential_blob: *mut u8,
persist: u32,
attribute_count: u32,
attributes: *mut c_void,
target_alias: *mut u16,
user_name: *mut u16,
}
#[link(name = "advapi32")]
extern "system" {
fn CredWriteW(credential: *const CredentialW, flags: u32) -> i32;
fn CredReadW(
target_name: *const u16,
typ: u32,
flags: u32,
credential: *mut *mut CredentialW,
) -> i32;
fn CredFree(buffer: *mut c_void);
}
pub fn set_password(account: &str, password: &str) -> Result<()> {
let mut target = wide(&target(account));
let mut user = wide(account);
let mut blob = password.as_bytes().to_vec();
let cred = CredentialW {
flags: 0,
typ: CRED_TYPE_GENERIC,
target_name: target.as_mut_ptr(),
comment: std::ptr::null_mut(),
last_written: FileTime {
low_date_time: 0,
high_date_time: 0,
},
credential_blob_size: blob.len() as u32,
credential_blob: blob.as_mut_ptr(),
persist: CRED_PERSIST_LOCAL_MACHINE,
attribute_count: 0,
attributes: std::ptr::null_mut(),
target_alias: std::ptr::null_mut(),
user_name: user.as_mut_ptr(),
};
let ok = unsafe { CredWriteW(&cred, 0) };
if ok == 0 {
return Err(anyhow!("CredWriteW failed"));
}
Ok(())
}
pub fn get_password(account: &str) -> Result<String> {
let target = wide(&target(account));
let mut cred: *mut CredentialW = std::ptr::null_mut();
let ok = unsafe { CredReadW(target.as_ptr(), CRED_TYPE_GENERIC, 0, &mut cred) };
if ok == 0 || cred.is_null() {
return Err(anyhow!("CredReadW failed"));
}
let result = unsafe {
let c = &*cred;
let bytes = slice::from_raw_parts(
c.credential_blob,
c.credential_blob_size as usize,
);
String::from_utf8(bytes.to_vec()).context("credential is not UTF-8")
};
unsafe { CredFree(cred.cast()) };
result
}
fn target(account: &str) -> String {
format!("{}:{}", SERVICE, account)
}
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain(std::iter::once(0)).collect()
}
}
#[cfg(target_os = "macos")]
mod platform {
use super::*;
use std::ffi::{c_char, c_void, CString};
use std::slice;
const ERR_SEC_SUCCESS: i32 = 0;
const ERR_SEC_DUPLICATE_ITEM: i32 = -25299;
#[link(name = "Security", kind = "framework")]
extern "C" {
fn SecKeychainAddGenericPassword(
keychain: *mut c_void,
service_name_length: u32,
service_name: *const c_char,
account_name_length: u32,
account_name: *const c_char,
password_length: u32,
password_data: *const c_void,
item_ref: *mut *mut c_void,
) -> i32;
fn SecKeychainFindGenericPassword(
keychain_or_array: *mut c_void,
service_name_length: u32,
service_name: *const c_char,
account_name_length: u32,
account_name: *const c_char,
password_length: *mut u32,
password_data: *mut *mut c_void,
item_ref: *mut *mut c_void,
) -> i32;
fn SecKeychainItemModifyContent(
item_ref: *mut c_void,
attr_list: *const c_void,
length: u32,
data: *const c_void,
) -> i32;
fn SecKeychainItemFreeContent(attr_list: *mut c_void, data: *mut c_void) -> i32;
fn CFRelease(cf: *const c_void);
}
pub fn set_password(account: &str, password: &str) -> Result<()> {
let service = cstr(SERVICE)?;
let account = cstr(account)?;
let pass = password.as_bytes();
let mut item: *mut c_void = std::ptr::null_mut();
let status = unsafe {
SecKeychainAddGenericPassword(
std::ptr::null_mut(),
SERVICE.len() as u32,
service.as_ptr(),
account.as_bytes().len() as u32,
account.as_ptr(),
pass.len() as u32,
pass.as_ptr().cast(),
&mut item,
)
};
if status == ERR_SEC_SUCCESS {
if !item.is_null() {
unsafe { CFRelease(item.cast()) };
}
return Ok(());
}
if status != ERR_SEC_DUPLICATE_ITEM {
return Err(anyhow!("SecKeychainAddGenericPassword failed: {status}"));
}
let item = find_item(
service.as_ptr(),
SERVICE.len(),
account.as_ptr(),
account.as_bytes().len(),
)?;
let status = unsafe {
SecKeychainItemModifyContent(
item,
std::ptr::null(),
pass.len() as u32,
pass.as_ptr().cast(),
)
};
unsafe { CFRelease(item.cast()) };
if status != ERR_SEC_SUCCESS {
return Err(anyhow!("SecKeychainItemModifyContent failed: {status}"));
}
Ok(())
}
pub fn get_password(account: &str) -> Result<String> {
let service = cstr(SERVICE)?;
let account = cstr(account)?;
let mut len = 0u32;
let mut data: *mut c_void = std::ptr::null_mut();
let mut item: *mut c_void = std::ptr::null_mut();
let status = unsafe {
SecKeychainFindGenericPassword(
std::ptr::null_mut(),
SERVICE.len() as u32,
service.as_ptr(),
account.as_bytes().len() as u32,
account.as_ptr(),
&mut len,
&mut data,
&mut item,
)
};
if status != ERR_SEC_SUCCESS {
return Err(anyhow!("SecKeychainFindGenericPassword failed: {status}"));
}
let result = unsafe {
let bytes = slice::from_raw_parts(data.cast::<u8>(), len as usize);
String::from_utf8(bytes.to_vec()).context("credential is not UTF-8")
};
unsafe {
SecKeychainItemFreeContent(std::ptr::null_mut(), data);
if !item.is_null() {
CFRelease(item.cast());
}
}
result
}
fn find_item(
service: *const c_char,
service_len: usize,
account: *const c_char,
account_len: usize,
) -> Result<*mut c_void> {
let mut item: *mut c_void = std::ptr::null_mut();
let status = unsafe {
SecKeychainFindGenericPassword(
std::ptr::null_mut(),
service_len as u32,
service,
account_len as u32,
account,
std::ptr::null_mut(),
std::ptr::null_mut(),
&mut item,
)
};
if status != ERR_SEC_SUCCESS || item.is_null() {
return Err(anyhow!("SecKeychainFindGenericPassword failed: {status}"));
}
Ok(item)
}
fn cstr(s: &str) -> Result<CString> {
CString::new(s).context("credential string contains NUL")
}
}
#[cfg(all(unix, not(target_os = "macos")))]
mod platform {
use super::*;
use std::io::Write;
use std::process::{Command, Stdio};
pub fn set_password(account: &str, password: &str) -> Result<()> {
let mut child = Command::new("secret-tool")
.args([
"store",
"--label",
"meatshell session password",
"app",
SERVICE,
"account",
account,
])
.stdin(Stdio::piped())
.spawn()
.context("secret-tool is not available")?;
child
.stdin
.as_mut()
.context("secret-tool stdin unavailable")?
.write_all(password.as_bytes())
.context("write password to secret-tool")?;
let status = child.wait().context("wait for secret-tool")?;
if !status.success() {
return Err(anyhow!("secret-tool store failed"));
}
Ok(())
}
pub fn get_password(account: &str) -> Result<String> {
let out = Command::new("secret-tool")
.args(["lookup", "app", SERVICE, "account", account])
.output()
.context("secret-tool is not available")?;
if !out.status.success() {
return Err(anyhow!("secret-tool lookup failed"));
}
let mut s = String::from_utf8(out.stdout).context("credential is not UTF-8")?;
while s.ends_with('\n') || s.ends_with('\r') {
s.pop();
}
Ok(s)
}
}
#[cfg(not(any(unix, windows)))]
mod platform {
use super::*;
pub fn set_password(_account: &str, _password: &str) -> Result<()> {
Err(anyhow!("no keychain backend for this platform"))
}
pub fn get_password(_account: &str) -> Result<String> {
Err(anyhow!("no keychain backend for this platform"))
}
}

286
src/known_hosts.rs Normal file
View File

@@ -0,0 +1,286 @@
//! Minimal OpenSSH `known_hosts` support.
//!
//! Behaviour is deliberately conservative:
//! - match entries from the user's `~/.ssh/known_hosts` and meatshell's own
//! config-local `known_hosts`;
//! - trust-on-first-use into meatshell's file when no entry exists;
//! - reject the connection when a matching host entry exists with a different
//! key.
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use directories::UserDirs;
use ssh_key::PublicKey;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HostKeyStatus {
Verified,
Added,
Changed,
}
/// Check `host:port` against OpenSSH known_hosts files, adding a new entry to
/// `app_known_hosts` on first use.
pub fn check_or_trust_in(
host: &str,
port: u16,
key: &PublicKey,
app_known_hosts: &Path,
) -> Result<HostKeyStatus> {
let key = public_key_parts(key)?;
let targets = host_patterns(host, port);
let mut saw_same_key_type = false;
for (path, can_reject) in known_hosts_files(app_known_hosts) {
let Ok(raw) = fs::read_to_string(&path) else {
continue;
};
for line in raw.lines() {
let Some(entry) = parse_line(line) else {
continue;
};
if !entry.hosts.iter().any(|h| targets.iter().any(|t| host_matches(h, t))) {
continue;
}
if entry.key_type == key.0 {
if can_reject {
saw_same_key_type = true;
}
if entry.key_data == key.1 {
return Ok(HostKeyStatus::Verified);
}
}
}
}
if saw_same_key_type {
return Ok(HostKeyStatus::Changed);
}
append_app_known_host(app_known_hosts, host, port, &key.0, &key.1)?;
Ok(HostKeyStatus::Added)
}
fn known_hosts_files(app_known_hosts: &Path) -> Vec<(PathBuf, bool)> {
let mut out = Vec::new();
if let Some(home) = UserDirs::new() {
out.push((home.home_dir().join(".ssh").join("known_hosts"), false));
}
out.push((app_known_hosts.to_path_buf(), true));
out
}
fn append_app_known_host(
path: &Path,
host: &str,
port: u16,
key_type: &str,
key_data: &str,
) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
#[cfg(unix)]
let existed = path.exists();
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("failed to open {}", path.display()))?;
#[cfg(unix)]
if !existed {
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&path, fs::Permissions::from_mode(0o600));
}
let host = if port == 22 {
host.to_string()
} else {
format!("[{host}]:{port}")
};
writeln!(file, "{host} {key_type} {key_data} meatshell")
.with_context(|| format!("failed to write {}", path.display()))?;
Ok(())
}
fn public_key_parts(key: &PublicKey) -> Result<(String, String)> {
let encoded = key.to_openssh().context("encode server public key")?;
let mut fields = encoded.split_whitespace();
let key_type = fields
.next()
.context("server public key missing type")?
.to_string();
let key_data = fields
.next()
.context("server public key missing data")?
.to_string();
Ok((key_type, key_data))
}
fn host_patterns(host: &str, port: u16) -> Vec<String> {
let mut out = Vec::new();
out.push(host.to_ascii_lowercase());
out.push(format!("[{}]:{}", host.to_ascii_lowercase(), port));
if port == 22 {
out.push(format!("[{}]:22", host.to_ascii_lowercase()));
}
out
}
#[derive(Debug, PartialEq, Eq)]
struct KnownHostEntry {
hosts: Vec<String>,
key_type: String,
key_data: String,
}
fn parse_line(line: &str) -> Option<KnownHostEntry> {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
return None;
}
let mut fields = trimmed.split_whitespace();
let first = fields.next()?;
let hosts = if first.starts_with('@') {
// Marker such as @cert-authority or @revoked. We only handle ordinary
// host-key entries here; markers are skipped rather than misread.
return None;
} else {
first
};
if hosts.starts_with("|1|") {
// Hashed OpenSSH entries need the per-line HMAC salt. Keep support
// explicit by ignoring them instead of producing false matches.
return None;
}
let key_type = fields.next()?.to_string();
let key_data = fields.next()?.to_string();
Some(KnownHostEntry {
hosts: hosts.split(',').map(|h| h.to_ascii_lowercase()).collect(),
key_type,
key_data,
})
}
fn host_matches(pattern: &str, target: &str) -> bool {
if pattern == target {
return true;
}
if let Some(suffix) = pattern.strip_prefix("*.") {
return target
.strip_suffix(suffix)
.map(|prefix| prefix.ends_with('.') && !prefix.trim_end_matches('.').is_empty())
.unwrap_or(false);
}
false
}
#[cfg(test)]
mod tests {
use super::{
check_or_trust_in, host_matches, known_hosts_files, parse_line, HostKeyStatus,
KnownHostEntry,
};
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
fn fixture_key(key_type: &str, key_data: &str) -> ssh_key::PublicKey {
format!("{key_type} {key_data}")
.parse()
.expect("valid public key fixture")
}
fn temp_known_hosts_path(name: &str) -> std::path::PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir()
.join("meatshell-known-hosts-tests")
.join(format!("{name}-{unique}"))
}
#[test]
fn parses_plain_known_hosts_line() {
assert_eq!(
parse_line("example.com,192.0.2.1 ssh-ed25519 AAAA comment"),
Some(KnownHostEntry {
hosts: vec!["example.com".into(), "192.0.2.1".into()],
key_type: "ssh-ed25519".into(),
key_data: "AAAA".into(),
})
);
}
#[test]
fn skips_hashed_and_marker_entries() {
assert!(parse_line("|1|salt|hash ssh-ed25519 AAAA").is_none());
assert!(parse_line("@cert-authority *.example.com ssh-ed25519 AAAA").is_none());
}
#[test]
fn matches_exact_and_wildcard_hosts() {
assert!(host_matches("example.com", "example.com"));
assert!(host_matches("*.example.com", "db.prod.example.com"));
assert!(!host_matches("*.example.com", "example.com"));
}
#[test]
fn accepts_additional_key_type_for_known_host() {
let path = temp_known_hosts_path("additional-key-type");
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(
&path,
"10.31.88.88 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHcL1R5w/NbHtGXf+qXCDP1KIXL4SMLJK/7UKcaGEJXC\n",
)
.unwrap();
let key = fixture_key(
"ecdsa-sha2-nistp256",
"AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBHwf2HMM5TRXvo2SQJjsNkiDD5KqiiNjrGVv3UUh+mMT5RHxiRtOnlqvjhQtBq0VpmpCV/PwUdhOig4vkbqAcEc=",
);
let status = check_or_trust_in("10.31.88.88", 22, &key, &path).unwrap();
assert_eq!(status, HostKeyStatus::Added);
let raw = fs::read_to_string(path).unwrap();
assert!(raw.contains("ssh-ed25519"));
assert!(raw.contains("ecdsa-sha2-nistp256"));
}
#[test]
fn rejects_changed_key_for_same_key_type() {
let path = temp_known_hosts_path("changed-key");
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(
&path,
"10.31.88.88 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFgFZtmFxw40sXfFl6kOVb9zVTiDjXCh8y2jhkJ/AH63\n",
)
.unwrap();
let key = fixture_key(
"ssh-ed25519",
"AAAAC3NzaC1lZDI1NTE5AAAAIHcL1R5w/NbHtGXf+qXCDP1KIXL4SMLJK/7UKcaGEJXC",
);
let status = check_or_trust_in("10.31.88.88", 22, &key, &path).unwrap();
assert_eq!(status, HostKeyStatus::Changed);
}
#[test]
fn only_app_known_hosts_can_reject_changed_keys() {
let app_path = temp_known_hosts_path("source-flags");
let files = known_hosts_files(&app_path);
assert!(files.len() >= 2);
assert!(files.iter().any(|(path, can_reject)| path == &app_path && *can_reject));
assert!(files.iter().any(|(_, can_reject)| !*can_reject));
}
}

46
src/main.rs Normal file
View File

@@ -0,0 +1,46 @@
// Entry point. Wires the Slint UI to the config store, system sampler and
// SSH session manager.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod app;
mod config;
mod debug_log;
mod i18n;
mod keychain;
mod known_hosts;
mod proxy;
mod serial;
mod sftp;
mod ssh;
mod ssh_config;
mod system;
mod telnet;
fn main() -> anyhow::Result<()> {
let log_path = debug_log::init();
// ── IME policy ───────────────────────────────────────────────────────────
// NOTE: We deliberately DO **NOT** call `ImmDisableIME` here.
//
// An earlier version disabled the IME for the whole Slint event-loop thread
// to work around a vim `:q!` glitch (Chinese IMEs intercept letter keys and,
// on a Shift press, discard the in-flight pinyin). But disabling the IME
// also makes 中文输入 completely impossible — there is no composition window
// at all, which is exactly the "无法输入任何中文" bug.
//
// Chinese input now flows through the hidden `ime-input` TextInput in
// terminal_view.slint: composition happens there, and committed text is
// forwarded to the PTY via the `edited` callback. The vim/Shift side-effects
// are handled instead by the C0-marker + 3-layer Backspace filters in
// `app::on_send_key`, so we no longer need (and must not use) ImmDisableIME.
if let Err(err) = app::run() {
tracing::error!(error = %format!("{err:#}"), "application exited with error");
return Err(err);
}
if let Some(path) = log_path.filter(|_| debug_log::log_paths_enabled()) {
tracing::debug!(log = %path.display(), "application exited normally");
}
Ok(())
}

176
src/proxy.rs Normal file
View File

@@ -0,0 +1,176 @@
//! Outbound proxy support for SSH / SFTP connections (issue #7).
//!
//! Establishes the TCP stream to the target host **through a proxy**, then the
//! caller hands that stream to `russh::client::connect_stream`. Both proxy
//! kinds end up as a transparent `TcpStream`:
//!
//! * **SOCKS5** (`socks5://` / `socks5h://`) via `tokio-socks`; after the
//! handshake we unwrap to the inner `TcpStream`.
//! * **HTTP / HTTPS CONNECT** (`http://` / `https://`): we issue an HTTP
//! `CONNECT host:port` and reuse the same socket as the tunnel.
//!
//! The proxy is taken from the per-session setting, falling back to the standard
//! `ALL_PROXY` / `all_proxy` environment variable.
use anyhow::{anyhow, bail, Context, Result};
use base64::Engine as _;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use crate::config::Secret;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum ProxyKind {
Socks5,
Http,
}
#[derive(Clone)]
pub struct ProxyConfig {
kind: ProxyKind,
host: String,
port: u16,
// (user, password). The password is wrapped in `Secret` so it is zeroed on
// drop and never printed; see the manual `Debug` below for the user part.
auth: Option<(String, Secret)>,
}
// Manual `Debug` so proxy credentials can never leak via `{:?}` / tracing
// (issue #32). Host/port/kind stay visible for diagnostics; auth is redacted.
impl std::fmt::Debug for ProxyConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProxyConfig")
.field("kind", &self.kind)
.field("host", &self.host)
.field("port", &self.port)
.field("auth", &self.auth.as_ref().map(|_| "[redacted]"))
.finish()
}
}
/// Resolve the proxy for a session: the explicit `session_proxy` string if set,
/// otherwise the `ALL_PROXY` / `all_proxy` environment variable. Returns `None`
/// for a direct connection.
pub fn resolve(session_proxy: &str) -> Option<ProxyConfig> {
let s = session_proxy.trim();
if !s.is_empty() {
return parse(s);
}
for var in ["ALL_PROXY", "all_proxy"] {
if let Ok(v) = std::env::var(var) {
if !v.trim().is_empty() {
return parse(v.trim());
}
}
}
None
}
/// Parse a proxy URL: `scheme://[user:pass@]host:port`.
fn parse(url: &str) -> Option<ProxyConfig> {
let (scheme, rest) = url.split_once("://").unwrap_or(("socks5", url));
let kind = match scheme.to_ascii_lowercase().as_str() {
"socks5" | "socks5h" | "socks" => ProxyKind::Socks5,
"http" | "https" => ProxyKind::Http,
_ => return None,
};
// Optional userinfo before '@'.
let (auth, hostport) = match rest.rsplit_once('@') {
Some((userinfo, hp)) => {
let (u, p) = userinfo.split_once(':').unwrap_or((userinfo, ""));
(Some((u.to_string(), Secret::new(p))), hp)
}
None => (None, rest),
};
let hostport = hostport.trim_end_matches('/');
let (host, port) = hostport.rsplit_once(':')?;
let port: u16 = port.parse().ok()?;
if host.is_empty() {
return None;
}
Some(ProxyConfig {
kind,
host: host.to_string(),
port,
auth,
})
}
/// Human-readable description of where we're connecting (for status messages).
pub fn describe(cfg: &ProxyConfig) -> String {
let scheme = match cfg.kind {
ProxyKind::Socks5 => "socks5",
ProxyKind::Http => "http",
};
format!("{}://{}:{}", scheme, cfg.host, cfg.port)
}
/// Open a TCP stream to `target_host:target_port` through the proxy.
pub async fn connect(cfg: &ProxyConfig, target_host: &str, target_port: u16) -> Result<TcpStream> {
match cfg.kind {
ProxyKind::Socks5 => connect_socks5(cfg, target_host, target_port).await,
ProxyKind::Http => connect_http(cfg, target_host, target_port).await,
}
}
async fn connect_socks5(cfg: &ProxyConfig, host: &str, port: u16) -> Result<TcpStream> {
use tokio_socks::tcp::Socks5Stream;
let proxy = (cfg.host.as_str(), cfg.port);
let target = (host, port);
let stream = match &cfg.auth {
Some((u, p)) => Socks5Stream::connect_with_password(proxy, target, u, p.as_str())
.await
.context("SOCKS5 proxy connect failed")?,
None => Socks5Stream::connect(proxy, target)
.await
.context("SOCKS5 proxy connect failed")?,
};
// After the handshake the underlying socket is a transparent tunnel.
Ok(stream.into_inner())
}
async fn connect_http(cfg: &ProxyConfig, host: &str, port: u16) -> Result<TcpStream> {
let mut s = TcpStream::connect((cfg.host.as_str(), cfg.port))
.await
.with_context(|| format!("connect to HTTP proxy {}:{} failed", cfg.host, cfg.port))?;
let mut req = format!("CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n");
if let Some((u, p)) = &cfg.auth {
let token =
base64::engine::general_purpose::STANDARD.encode(format!("{u}:{}", p.as_str()));
req.push_str(&format!("Proxy-Authorization: Basic {token}\r\n"));
}
req.push_str("Proxy-Connection: keep-alive\r\n\r\n");
s.write_all(req.as_bytes())
.await
.context("write CONNECT to proxy")?;
// Read response headers up to the blank line, bounded.
let mut buf = Vec::with_capacity(256);
let mut byte = [0u8; 1];
loop {
let n = s.read(&mut byte).await.context("read proxy response")?;
if n == 0 {
bail!("proxy closed the connection during CONNECT");
}
buf.push(byte[0]);
if buf.ends_with(b"\r\n\r\n") {
break;
}
if buf.len() > 8192 {
bail!("proxy CONNECT response too large");
}
}
let head = String::from_utf8_lossy(&buf);
let status_line = head.lines().next().unwrap_or("");
// Expect "HTTP/1.x 200 ...".
let ok = status_line
.split_whitespace()
.nth(1)
.map(|c| c == "200")
.unwrap_or(false);
if !ok {
return Err(anyhow!("proxy CONNECT rejected: {}", status_line.trim()));
}
Ok(s)
}

211
src/serial.rs Normal file
View File

@@ -0,0 +1,211 @@
//! Serial-port session worker (issue #14 / #17).
//!
//! Mirrors the public surface of [`crate::ssh::spawn_session`] so the rest of
//! the UI pipeline (terminal output, key input, tab lifecycle) is reused
//! unchanged: it returns a [`SessionHandle`] plus an
//! [`UnboundedReceiver<SessionEvent>`].
//!
//! Unlike SSH there is no remote PTY, no SFTP and no resource monitor — a
//! serial line is just a raw byte pipe to a switch / router / MCU console.
//! The `serialport` crate is blocking, so the read side runs on a dedicated OS
//! thread and writes happen via `spawn_blocking`.
use std::io::{Read, Write};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use anyhow::{Context, Result};
use serialport::{DataBits, FlowControl, Parity, StopBits};
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
use crate::config::Session;
use crate::i18n::t;
use crate::ssh::{SessionCommand, SessionEvent, SessionHandle};
/// Spawn a serial-port session. See module docs for why the signature mirrors
/// `spawn_session` (minus the PTY size, which a serial line has no notion of).
pub fn spawn_serial_session(
runtime: &tokio::runtime::Handle,
tab_id: String,
session: Session,
) -> (SessionHandle, UnboundedReceiver<SessionEvent>) {
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<SessionCommand>();
let (evt_tx, evt_rx) = mpsc::unbounded_channel::<SessionEvent>();
let evt_for_task = evt_tx.clone();
let join = runtime.spawn(async move {
if let Err(err) = run_serial(session, cmd_rx, evt_for_task.clone()).await {
let _ = evt_for_task.send(SessionEvent::Closed(format!("{err:#}")));
}
});
(
SessionHandle {
tab_id,
commands: cmd_tx,
join,
},
evt_rx,
)
}
fn parse_data_bits(n: u8) -> DataBits {
match n {
5 => DataBits::Five,
6 => DataBits::Six,
7 => DataBits::Seven,
_ => DataBits::Eight,
}
}
fn parse_stop_bits(n: u8) -> StopBits {
match n {
2 => StopBits::Two,
_ => StopBits::One,
}
}
fn parse_parity(s: &str) -> Parity {
match s {
"odd" => Parity::Odd,
"even" => Parity::Even,
_ => Parity::None,
}
}
fn parse_flow(s: &str) -> FlowControl {
match s {
"hardware" => FlowControl::Hardware,
"software" => FlowControl::Software,
_ => FlowControl::None,
}
}
async fn run_serial(
session: Session,
mut commands: UnboundedReceiver<SessionCommand>,
events: UnboundedSender<SessionEvent>,
) -> Result<()> {
let port_name = session.serial_port.trim().to_string();
if port_name.is_empty() {
return Err(anyhow::anyhow!(t("串口号为空", "serial port is empty")));
}
let _ = events.send(SessionEvent::Status(format!(
"{} {} @ {}",
t("打开串口", "Opening serial"),
port_name,
session.baud_rate
)));
// Open on a blocking thread — serialport::open() can stall on a busy device.
let open_name = port_name.clone();
let baud = session.baud_rate;
let data_bits = parse_data_bits(session.data_bits);
let stop_bits = parse_stop_bits(session.stop_bits);
let parity = parse_parity(&session.parity);
let flow = parse_flow(&session.flow_control);
let port = tokio::task::spawn_blocking(move || {
serialport::new(&open_name, baud)
.data_bits(data_bits)
.stop_bits(stop_bits)
.parity(parity)
.flow_control(flow)
// Short read timeout so the reader thread can poll the stop flag.
.timeout(Duration::from_millis(50))
.open()
})
.await
.context("serial open task panicked")?
.with_context(|| format!("{} {}", t("打开串口失败", "failed to open serial port"), port_name))?;
// A second handle for writing so the reader thread can own the read side.
let writer = port
.try_clone()
.context("failed to clone serial handle for writing")?;
let writer = Arc::new(Mutex::new(writer));
let _ = events.send(SessionEvent::Connected);
let _ = events.send(SessionEvent::Status(format!(
"{} {} @ {} {}{}{}",
t("已连接", "Connected"),
port_name,
session.baud_rate,
session.data_bits,
parity_letter(&session.parity),
session.stop_bits,
)));
// --- Reader thread ------------------------------------------------------
let running = Arc::new(AtomicBool::new(true));
let reader_running = running.clone();
let reader_events = events.clone();
let reader_handle = std::thread::spawn(move || {
let mut port = port;
let mut buf = [0u8; 4096];
while reader_running.load(Ordering::Relaxed) {
match port.read(&mut buf) {
Ok(0) => {}
Ok(n) => {
let text = String::from_utf8_lossy(&buf[..n]).into_owned();
if reader_events.send(SessionEvent::Output(text)).is_err() {
break;
}
}
Err(e) if e.kind() == std::io::ErrorKind::TimedOut => continue,
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => {
let _ = reader_events.send(SessionEvent::Closed(format!(
"{}: {e}",
t("串口读取错误", "serial read error")
)));
break;
}
}
}
});
// --- Command pump -------------------------------------------------------
while let Some(cmd) = commands.recv().await {
match cmd {
SessionCommand::RawInput(bytes) => {
// Never log keystroke bytes — they can be passwords (#15).
tracing::debug!("serial write len={} bytes", bytes.len());
let w = writer.clone();
let res = tokio::task::spawn_blocking(move || {
let mut guard = w.lock().unwrap();
guard.write_all(&bytes).and_then(|_| guard.flush())
})
.await;
if let Ok(Err(e)) = res {
let _ = events.send(SessionEvent::Closed(format!(
"{}: {e}",
t("串口写入失败", "serial write failed")
)));
break;
}
}
// A serial line has no window size; nothing to propagate.
SessionCommand::Resize(_, _) => {}
SessionCommand::Close => break,
}
}
// Stop the reader thread and wait for it to drain.
running.store(false, Ordering::Relaxed);
let _ = reader_handle.join();
let _ = events.send(SessionEvent::Closed(
t("串口已关闭", "serial port closed").into(),
));
Ok(())
}
/// Single-letter parity tag for the status line (8N1 style).
fn parity_letter(parity: &str) -> &'static str {
match parity {
"odd" => "O",
"even" => "E",
_ => "N",
}
}

1172
src/sftp.rs Normal file

File diff suppressed because it is too large Load Diff

962
src/ssh.rs Normal file
View File

@@ -0,0 +1,962 @@
//! SSH session manager.
//!
//! Each open terminal tab maps to exactly one `SshSession`. The session runs
//! on the shared Tokio runtime; commands come in via an MPSC channel and
//! output lines are pushed back via an `UnboundedSender<SessionEvent>`.
use std::panic::AssertUnwindSafe;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{anyhow, Context, Result};
use async_trait::async_trait;
use futures::FutureExt;
use russh::client::{self, Handle, Handler};
use russh::keys::key::PrivateKeyWithHashAlg;
use russh::keys::load_secret_key;
use russh::{ChannelId, ChannelMsg, Disconnect};
use ssh_key::{HashAlg, PublicKey};
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
use tokio::task::JoinHandle;
use crate::config::{AuthMethod, Session};
use crate::i18n::t;
// ---------------------------------------------------------------------------
// SFTP-related shared types
// ---------------------------------------------------------------------------
/// Metadata for a single remote filesystem entry returned by SFTP listing.
#[derive(Debug, Clone)]
pub struct RemoteEntry {
pub name: String,
pub full_path: String,
pub is_dir: bool,
/// Raw size in bytes (0 for directories or unknown).
pub size: u64,
/// Modification time as Unix timestamp (seconds, u32 = SFTP wire format).
pub modified: u32,
}
/// One node in the remote directory tree panel.
#[derive(Debug, Clone)]
pub struct RemoteTreeNode {
pub path: String,
pub name: String,
pub depth: u32,
pub expanded: bool,
pub has_children: bool,
}
/// Format a byte count as a human-readable string.
pub fn format_size(bytes: u64) -> String {
if bytes < 1_024 {
format!("{} B", bytes)
} else if bytes < 1_024 * 1_024 {
format!("{:.1} KB", bytes as f64 / 1_024.0)
} else if bytes < 1_024 * 1_024 * 1_024 {
format!("{:.1} MB", bytes as f64 / (1_024.0 * 1_024.0))
} else {
format!("{:.2} GB", bytes as f64 / (1_024.0 * 1_024.0 * 1_024.0))
}
}
/// Format a Unix timestamp as `YYYY-MM-DD HH:MM`.
pub fn format_mtime(ts: u32) -> String {
use chrono::{DateTime, TimeZone, Utc};
let dt: DateTime<Utc> = Utc
.timestamp_opt(ts as i64, 0)
.single()
.unwrap_or_else(Utc::now);
dt.format("%Y-%m-%d %H:%M").to_string()
}
/// Extract the remote path from an OSC 7 sequence embedded in `text`.
///
/// Format: `ESC ] 7 ; file://hostname/path BEL`
/// Returns the decoded absolute path component (without hostname).
pub fn extract_osc7_path(text: &str) -> Option<String> {
let bytes = text.as_bytes();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] != 0x1b || bytes[i + 1] != b']' {
i += 1;
continue;
}
let osc_start = i + 2;
i += 2;
// Scan for BEL (0x07) or ST (ESC \)
let mut end = i;
while end < bytes.len() {
if bytes[end] == 0x07 {
break;
} else if bytes[end] == 0x1b && end + 1 < bytes.len() && bytes[end + 1] == b'\\' {
break;
}
end += 1;
}
if end >= bytes.len() {
break;
}
if let Ok(content) = std::str::from_utf8(&bytes[osc_start..end]) {
if let Some(rest) = content.strip_prefix("7;file://") {
// rest = "hostname/path" or "/path" (empty hostname)
let path = if rest.starts_with('/') {
rest.to_string()
} else if let Some(slash) = rest.find('/') {
rest[slash..].to_string()
} else {
"/".to_string()
};
return Some(url_decode(&path));
}
}
i = end + 1;
}
None
}
/// Percent-decode a URL path segment (e.g. `%20` → space).
fn url_decode(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '%' {
let h1 = chars.next();
let h2 = chars.next();
match (h1, h2) {
(Some(a), Some(b)) => {
let hex = format!("{a}{b}");
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
result.push(byte as char);
} else {
result.push('%');
result.push(a);
result.push(b);
}
}
(Some(a), None) => {
result.push('%');
result.push(a);
}
_ => result.push('%'),
}
} else {
result.push(c);
}
}
result
}
/// Commands posted to the worker task by the UI.
#[derive(Debug)]
pub enum SessionCommand {
/// Send raw bytes directly to the PTY (individual keystrokes, no modification).
RawInput(Vec<u8>),
/// Notify the remote PTY of a terminal resize.
Resize(u32, u32),
/// Gracefully disconnect and drop the session.
Close,
}
/// Events emitted back to the UI thread.
#[derive(Debug, Clone)]
pub enum SessionEvent {
/// Free-form status text for the tab header / status line.
Status(String),
/// A chunk of stdout/stderr output from the remote shell.
Output(String),
/// Connection is up.
Connected,
/// Connection closed (either cleanly or after an error).
Closed(String),
/// Remote machine resource sample (from the monitor channel).
/// Memory/swap are in KiB (as reported by /proc/meminfo).
ResourceStats {
cpu_percent: f32,
cpu_cores: u32,
mem_used_kib: u64,
mem_total_kib: u64,
swap_used_kib: u64,
swap_total_kib: u64,
uptime_secs: u64,
/// Per-interface (name, rx_bytes_per_sec, tx_bytes_per_sec).
net: Vec<(String, u64, u64)>,
/// Per-filesystem (mount_point, available_bytes, total_bytes).
disks: Vec<(String, u64, u64)>,
/// Top processes: (resident_memory_bytes, cpu_percent, command).
processes: Vec<(u64, f32, String)>,
},
// --- SFTP events -------------------------------------------------------
/// The shell's current working directory changed (parsed from OSC 7).
CwdChanged(String),
/// SFTP directory listing arrived.
SftpEntries {
path: String,
entries: Vec<RemoteEntry>,
},
/// Free-form SFTP status message (progress, errors, etc.).
SftpStatus(String),
/// Directory tree structure changed (full rebuild pushed on every toggle).
SftpTreeUpdate(Vec<RemoteTreeNode>),
/// File-transfer progress / completion (download or upload).
SftpTransfer {
id: String,
name: String,
is_upload: bool,
transferred: u64,
total: u64,
state: u8, // 0 = active, 1 = done, 2 = error
msg: String,
},
}
/// Handle retained by the UI layer to talk to a running session.
pub struct SessionHandle {
#[allow(dead_code)] // used by future resize / reconnect flows
pub tab_id: String,
pub commands: UnboundedSender<SessionCommand>,
#[allow(dead_code)] // keep alive; detach on Drop is fine for v0.1
pub join: JoinHandle<()>,
}
impl SessionHandle {
pub fn send_raw(&self, bytes: Vec<u8>) {
let _ = self.commands.send(SessionCommand::RawInput(bytes));
}
pub fn resize(&self, cols: u32, rows: u32) {
let _ = self.commands.send(SessionCommand::Resize(cols, rows));
}
pub fn close(&self) {
let _ = self.commands.send(SessionCommand::Close);
}
}
/// Entry point: spawn a session on the shared tokio runtime.
///
/// `initial_cols` / `initial_rows` are the PTY dimensions to request when
/// opening the channel. Slint fires a `terminal-resize` callback very shortly
/// after the tab becomes active; passing the best-known size here avoids the
/// remote shell starting at a stale 80×24 and sending an extra SIGWINCH.
///
/// Returns a [`SessionHandle`] for the UI + an [`UnboundedReceiver`] the UI
/// should drain on the Slint event loop.
pub fn spawn_session(
runtime: &tokio::runtime::Handle,
tab_id: String,
session: Session,
known_hosts_path: PathBuf,
initial_cols: u32,
initial_rows: u32,
) -> (SessionHandle, UnboundedReceiver<SessionEvent>) {
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<SessionCommand>();
let (evt_tx, evt_rx) = mpsc::unbounded_channel::<SessionEvent>();
let evt_tx_for_task = evt_tx.clone();
let join = runtime.spawn(async move {
match AssertUnwindSafe(run_session(
session,
known_hosts_path,
cmd_rx,
evt_tx_for_task.clone(),
initial_cols,
initial_rows,
))
.catch_unwind()
.await
{
Ok(Ok(())) => {}
Ok(Err(err)) => {
tracing::error!(error = %format!("{err:#}"), "ssh session ended with error");
let _ = evt_tx_for_task.send(SessionEvent::Closed(format!("{err:#}")));
}
Err(_) => {
tracing::error!("ssh session task panicked");
let _ = evt_tx_for_task.send(SessionEvent::Closed(
t("SSH 任务崩溃,请查看 meatshell-debug.log", "SSH worker panicked; see meatshell-debug.log")
.into(),
));
}
}
});
(
SessionHandle {
tab_id,
commands: cmd_tx,
join,
},
evt_rx,
)
}
async fn run_session(
session: Session,
known_hosts_path: PathBuf,
mut commands: UnboundedReceiver<SessionCommand>,
events: UnboundedSender<SessionEvent>,
initial_cols: u32,
initial_rows: u32,
) -> Result<()> {
let _ = events.send(SessionEvent::Status(format!(
"{} {}@{}:{} ...",
t("连接中", "Connecting"),
session.user, session.host, session.port
)));
let config = Arc::new(client::Config {
inactivity_timeout: Some(std::time::Duration::from_secs(60 * 10)),
..<_>::default()
});
let handler = ClientHandler {
host: session.host.clone(),
port: session.port,
known_hosts_path,
};
let addr = format!("{}:{}", session.host, session.port);
// Connect directly, or tunnel through a SOCKS5 / HTTP proxy (issue #7).
let mut handle = match crate::proxy::resolve(&session.proxy) {
Some(p) => {
let _ = events.send(SessionEvent::Status(format!(
"{} {}{}",
t("经代理连接", "via proxy"),
crate::proxy::describe(&p),
addr
)));
let stream = crate::proxy::connect(&p, &session.host, session.port)
.await
.with_context(|| format!("proxy connect to {} failed", addr))?;
client::connect_stream(config, stream, handler)
.await
.with_context(|| format!("connect {} failed", addr))?
}
None => client::connect(config, addr.as_str(), handler)
.await
.with_context(|| format!("connect {} failed", addr))?,
};
// --- Auth ----------------------------------------------------------
let authed = match session.auth {
AuthMethod::Password => handle
.authenticate_password(&session.user, session.password.as_str())
.await
.context("password auth failed")?,
AuthMethod::Key => {
let raw = session.private_key_path.trim();
if raw.is_empty() {
return Err(anyhow!(t("私钥路径为空", "private key path is empty")));
}
// Normalise separators (we store `/` everywhere) and be forgiving if
// the user pointed at the `.pub` *public* key — the private key is the
// same path without that suffix.
let normalised = raw.replace('\\', "/");
let key_path = normalised
.strip_suffix(".pub")
.map(str::to_string)
.unwrap_or(normalised);
let keypair = load_secret_key(Path::new(&key_path), None)
.with_context(|| format!("failed to load key {key_path}"))?;
let hash = if keypair.algorithm().is_rsa() {
Some(HashAlg::Sha256)
} else {
None
};
let key_with_hash = PrivateKeyWithHashAlg::new(Arc::new(keypair), hash)
.context("invalid private key / hash algorithm combination")?;
handle
.authenticate_publickey(&session.user, key_with_hash)
.await
.context("publickey auth failed")?
}
};
if !authed {
let _ = events.send(SessionEvent::Closed(t("认证失败", "authentication failed").into()));
let _ = handle
.disconnect(Disconnect::ByApplication, "auth failed", "")
.await;
return Ok(());
}
// --- Shell channel --------------------------------------------------
let mut channel = handle
.channel_open_session()
.await
.context("open session channel")?;
channel
.request_pty(
true,
"xterm-256color",
initial_cols,
initial_rows,
0,
0,
&[],
)
.await
.context("request PTY")?;
channel.request_shell(true).await.context("request shell")?;
let _ = events.send(SessionEvent::Connected);
let _ = events.send(SessionEvent::Status(format!(
"{} {}@{}",
t("已连接", "Connected"),
session.user, session.host
)));
// Whether we have already injected the PROMPT_COMMAND setup.
// We wait for the first non-empty data chunk (the initial shell prompt)
// before sending so the command doesn't interleave with banner text.
let mut prompt_injected = false;
// PROMPT_COMMAND bash snippet. Single-quoted body prevents bash from
// expanding ${HOSTNAME}/${PWD} at definition time; printf interprets
// \033 / \007 as ESC / BEL. `eval "$PROMPT_COMMAND"` fires it once
// immediately so the SFTP panel gets the initial CWD right away.
const PROMPT_SETUP: &[u8] = b"export PROMPT_COMMAND='printf \"\\033]7;file://${HOSTNAME}${PWD}\\007\"' && eval \"$PROMPT_COMMAND\"\r";
// --- Remote resource monitor (separate exec channel) ----------------
// A tiny remote loop streams /proc/stat + /proc/meminfo every 2s; we parse
// it into CPU% / mem / swap for the sidebar. Best-effort: if the channel
// or exec fails (e.g. a non-Linux host without /proc), monitoring is
// silently skipped and the interactive shell is unaffected.
// Reset PATH to the standard system directories first (#27): the monitor
// runs over an exec channel, so a server with a hijacked PATH (or a
// BASH_ENV pointing at a malicious file) could otherwise shadow awk/cat/df/
// sleep with arbitrary binaries. A fixed PATH covering /usr/bin and /bin is
// more portable than hardcoding one absolute path per tool (their location
// differs across distros). Monitoring is best-effort, so even if this shell
// is unusual and the reset finds nothing, only the sidebar stats are lost.
const MON_CMD: &[u8] = b"PATH=/usr/bin:/bin:/usr/sbin:/sbin; export PATH; while :; do awk '/^cpu /{print}' /proc/stat; awk '/^(MemTotal|MemAvailable|SwapTotal|SwapFree):/{print}' /proc/meminfo; echo __CPUCORES__; nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || echo 0; echo __UPTIME__; cat /proc/uptime 2>/dev/null; cat /proc/net/dev; echo __PS__; ps -eo rss=,pcpu=,comm= --sort=-pcpu,-rss 2>/dev/null | head -n 8; echo __DF__; df -kP 2>/dev/null; echo __MSTICK__; sleep 2; done\n";
let mut mon_channel = match handle.channel_open_session().await {
Ok(ch) => match ch.exec(true, MON_CMD).await {
Ok(()) => Some(ch),
Err(e) => {
tracing::warn!("monitor exec failed: {e}");
None
}
},
Err(e) => {
tracing::warn!("monitor channel open failed: {e}");
None
}
};
let mut mon_buf = String::new();
let mut prev_cpu: Option<(u64, u64)> = None; // (total jiffies, idle jiffies)
let mut prev_net: std::collections::HashMap<String, (u64, u64)> =
std::collections::HashMap::new(); // iface -> (rx_bytes, tx_bytes)
let mut prev_net_at = std::time::Instant::now();
// --- Main pump ------------------------------------------------------
loop {
tokio::select! {
cmd = commands.recv() => {
match cmd {
Some(SessionCommand::RawInput(bytes)) => {
// Only log the byte count — never the bytes themselves,
// which are raw keystrokes and may contain passwords (#15).
tracing::debug!("ssh channel.data len={} bytes", bytes.len());
if let Err(err) = channel.data(&bytes[..]).await {
let _ = events.send(SessionEvent::Closed(format!("{}: {err}", t("写入失败", "write failed"))));
break;
}
}
Some(SessionCommand::Resize(cols, rows)) => {
let _ = channel.window_change(cols, rows, 0, 0).await;
}
Some(SessionCommand::Close) | None => {
let _ = channel.eof().await;
break;
}
}
}
msg = channel.wait() => {
match msg {
Some(ChannelMsg::Data { data }) => {
let text = String::from_utf8_lossy(&data).into_owned();
// Inject PROMPT_COMMAND after the first real shell output.
if !prompt_injected && !text.trim().is_empty() {
prompt_injected = true;
let _ = channel.data(PROMPT_SETUP).await;
}
// Scan for OSC 7 CWD notification injected by PROMPT_COMMAND.
if let Some(cwd) = extract_osc7_path(&text) {
tracing::debug!("OSC7 cwd={:?}", cwd);
let _ = events.send(SessionEvent::CwdChanged(cwd));
}
let _ = events.send(SessionEvent::Output(text));
}
Some(ChannelMsg::ExtendedData { data, ext: _ }) => {
let text = String::from_utf8_lossy(&data).into_owned();
let _ = events.send(SessionEvent::Output(text));
}
Some(ChannelMsg::ExitStatus { exit_status }) => {
let _ = events.send(SessionEvent::Status(
format!("{} (code {exit_status})", t("远程进程退出", "remote process exited")),
));
}
Some(ChannelMsg::Close) | None => {
break;
}
_ => {}
}
}
// Remote resource monitor channel. The `async { ... }` lets us poll
// an Option<Channel>: once the monitor channel closes we replace it
// with `pending()` so this arm simply never fires again.
mon = async {
match mon_channel.as_mut() {
Some(ch) => ch.wait().await,
None => std::future::pending().await,
}
} => {
match mon {
Some(ChannelMsg::Data { data }) => {
mon_buf.push_str(&String::from_utf8_lossy(&data));
// Process every complete sample terminated by the marker.
while let Some(idx) = mon_buf.find("__MSTICK__") {
let block = mon_buf[..idx].to_string();
let rest = mon_buf[idx + "__MSTICK__".len()..]
.trim_start_matches(['\r', '\n'])
.to_string();
mon_buf = rest;
if let Some(stats) = parse_monitor_block(
&block,
&mut prev_cpu,
&mut prev_net,
&mut prev_net_at,
) {
let _ = events.send(stats);
}
}
// Bound the leftover (incomplete) tail: a server that
// streams data but never emits the __MSTICK__ marker must
// not grow this buffer without limit (memory DoS, #27).
// A real sample is a few KiB; 1 MiB is a generous ceiling.
const MON_BUF_CAP: usize = 1 << 20;
if mon_buf.len() > MON_BUF_CAP {
mon_buf.clear();
}
}
Some(ChannelMsg::Close) | None => {
mon_channel = None;
}
_ => {}
}
}
}
}
let _ = handle
.disconnect(Disconnect::ByApplication, "bye", "")
.await;
let _ = events.send(SessionEvent::Closed(t("连接已关闭", "connection closed").into()));
Ok(())
}
/// Parse one monitor sample (a block of `/proc/stat` cpu line + `/proc/meminfo`
/// fields) into a [`SessionEvent::ResourceStats`].
///
/// CPU usage needs two consecutive `/proc/stat` snapshots; `prev` carries the
/// previous (total, idle) jiffies across calls. The first sample therefore
/// reports 0% (no baseline yet).
fn parse_monitor_block(
block: &str,
prev: &mut Option<(u64, u64)>,
prev_net: &mut std::collections::HashMap<String, (u64, u64)>,
prev_net_at: &mut std::time::Instant,
) -> Option<SessionEvent> {
let mut cpu_total = 0u64;
let mut cpu_idle = 0u64;
let mut have_cpu = false;
let mut mem_total = 0u64;
let mut mem_avail = 0u64;
let mut swap_total = 0u64;
let mut swap_free = 0u64;
let mut cpu_cores = 0u32;
let mut uptime_secs = 0u64;
// Raw /proc/net/dev counters this sample: iface -> (rx_bytes, tx_bytes).
let mut net_now: Vec<(String, u64, u64)> = Vec::new();
// Filesystems from `df -kP`: (mount, available_bytes, total_bytes).
let mut disks: Vec<(String, u64, u64)> = Vec::new();
let mut processes: Vec<(u64, f32, String)> = Vec::new();
let mut in_df = false;
let mut in_ps = false;
let mut expect_uptime = false;
let mut expect_cpu_cores = false;
// Cap how many interfaces / filesystems / processes we accept from one sample so a
// hostile server can't flood the parser and sidebar with fabricated rows
// (#27). No real machine has anywhere near this many.
const MAX_MON_ENTRIES: usize = 64;
const MAX_MON_PROCESSES: usize = 8;
for line in block.lines() {
if line == "__UPTIME__" {
expect_uptime = true;
continue;
}
if line == "__CPUCORES__" {
expect_cpu_cores = true;
continue;
}
if expect_cpu_cores {
cpu_cores = line.trim().parse::<u32>().unwrap_or(0);
expect_cpu_cores = false;
continue;
}
if expect_uptime {
uptime_secs = parse_uptime_secs(line);
expect_uptime = false;
continue;
}
if line == "__DF__" {
in_df = true;
in_ps = false;
continue;
}
if line == "__PS__" {
in_ps = true;
in_df = false;
continue;
}
if in_ps {
if processes.len() < MAX_MON_PROCESSES {
if let Some(process) = parse_ps_line(line) {
processes.push(process);
}
}
continue;
}
if in_df {
if disks.len() < MAX_MON_ENTRIES {
if let Some(d) = parse_df_line(line) {
disks.push(d);
}
}
continue;
}
if let Some(rest) = line.strip_prefix("cpu ") {
let nums: Vec<u64> = rest
.split_whitespace()
.filter_map(|x| x.parse().ok())
.collect();
// user nice system idle iowait irq softirq steal ...
if nums.len() >= 4 {
// Saturating arithmetic: a server can send arbitrary jiffy
// values, and a plain sum/add would panic on overflow in debug.
cpu_total = nums.iter().copied().fold(0u64, u64::saturating_add);
cpu_idle = nums[3].saturating_add(nums.get(4).copied().unwrap_or(0)); // idle + iowait
have_cpu = true;
}
} else if let Some(v) = line.strip_prefix("MemTotal:") {
mem_total = parse_meminfo_kib(v);
} else if let Some(v) = line.strip_prefix("MemAvailable:") {
mem_avail = parse_meminfo_kib(v);
} else if let Some(v) = line.strip_prefix("SwapTotal:") {
swap_total = parse_meminfo_kib(v);
} else if let Some(v) = line.strip_prefix("SwapFree:") {
swap_free = parse_meminfo_kib(v);
} else if net_now.len() < MAX_MON_ENTRIES {
if let Some((iface, counters)) = parse_net_dev_line(line) {
net_now.push((iface, counters.0, counters.1));
}
}
}
// Convert raw byte counters into per-second rates using the previous sample.
let now = std::time::Instant::now();
let elapsed = now.duration_since(*prev_net_at).as_secs_f64().max(0.001);
let mut net: Vec<(String, u64, u64)> = Vec::new();
if !net_now.is_empty() {
for (iface, rx, tx) in &net_now {
if let Some((prx, ptx)) = prev_net.get(iface) {
let rx_bps = (rx.saturating_sub(*prx) as f64 / elapsed) as u64;
let tx_bps = (tx.saturating_sub(*ptx) as f64 / elapsed) as u64;
net.push((iface.clone(), rx_bps, tx_bps));
}
}
prev_net.clear();
for (iface, rx, tx) in net_now {
prev_net.insert(iface, (rx, tx));
}
*prev_net_at = now;
// Show busiest first so the default-selected NIC is the active one.
net.sort_by(|a, b| (b.1 + b.2).cmp(&(a.1 + a.2)));
}
let cpu_percent = if have_cpu {
let result = match *prev {
Some((ptotal, pidle)) => {
let dt = cpu_total.saturating_sub(ptotal);
let di = cpu_idle.saturating_sub(pidle);
if dt > 0 {
(1.0 - di as f32 / dt as f32).clamp(0.0, 1.0)
} else {
0.0
}
}
None => 0.0,
};
*prev = Some((cpu_total, cpu_idle));
result
} else {
0.0
};
// Need at least memory numbers to be a useful sample.
if mem_total == 0 {
return None;
}
processes.sort_by(|a, b| {
b.1.total_cmp(&a.1)
.then_with(|| b.0.cmp(&a.0))
});
processes.truncate(5);
Some(SessionEvent::ResourceStats {
cpu_percent,
cpu_cores,
mem_used_kib: mem_total.saturating_sub(mem_avail),
mem_total_kib: mem_total,
swap_used_kib: swap_total.saturating_sub(swap_free),
swap_total_kib: swap_total,
uptime_secs,
net,
disks,
processes,
})
}
fn parse_uptime_secs(s: &str) -> u64 {
s.split_whitespace()
.next()
.and_then(|x| x.parse::<f64>().ok())
.filter(|x| x.is_finite() && *x >= 0.0)
.map(|x| x as u64)
.unwrap_or(0)
}
/// Parse one `df -kP` data line into `(mount, available_bytes, total_bytes)`.
/// Columns: `Filesystem 1024-blocks Used Available Capacity Mounted-on`.
fn parse_df_line(line: &str) -> Option<(String, u64, u64)> {
let f: Vec<&str> = line.split_whitespace().collect();
if f.len() < 6 || f[0] == "Filesystem" {
return None;
}
let total_kb: u64 = f[1].parse().ok()?;
let avail_kb: u64 = f[3].parse().ok()?;
if total_kb == 0 {
return None;
}
// Mount point is the last column (joined in case it contains spaces).
let mount = f[5..].join(" ");
// Saturating: a server can report arbitrary block counts; KiB→bytes must
// not overflow-panic in debug (#27).
Some((mount, avail_kb.saturating_mul(1024), total_kb.saturating_mul(1024)))
}
/// Extract the leading integer (KiB) from a `/proc/meminfo` value like
/// `" 3288560 kB"`.
fn parse_meminfo_kib(s: &str) -> u64 {
s.split_whitespace()
.next()
.and_then(|x| x.parse().ok())
.unwrap_or(0)
}
fn parse_ps_line(line: &str) -> Option<(u64, f32, String)> {
let mut parts = line.split_whitespace();
let rss_kib = parts.next()?.parse::<u64>().ok()?;
let cpu = parts
.next()?
.trim_end_matches('%')
.parse::<f32>()
.ok()
.filter(|v| v.is_finite())
.unwrap_or(0.0);
let name = parts.collect::<Vec<_>>().join(" ");
if name.is_empty() {
return None;
}
Some((rss_kib.saturating_mul(1024), cpu.max(0.0), name))
}
/// Parse one `/proc/net/dev` data line into `(iface, (rx_bytes, tx_bytes))`.
/// Format: ` eth0: <rx_bytes> <rx_pkts> ... <tx_bytes> <tx_pkts> ...`
/// (16 numeric columns; rx_bytes is col 0, tx_bytes is col 8). The `lo`
/// loopback interface is skipped — it never reflects real traffic.
fn parse_net_dev_line(line: &str) -> Option<(String, (u64, u64))> {
let (name, rest) = line.split_once(':')?;
let iface = name.trim();
if iface.is_empty() || iface == "lo" || iface.contains(' ') {
return None;
}
let nums: Vec<u64> = rest
.split_whitespace()
.filter_map(|x| x.parse().ok())
.collect();
if nums.len() < 9 {
return None;
}
Some((iface.to_string(), (nums[0], nums[8])))
}
/// SSH client handler with OpenSSH-style known_hosts verification.
struct ClientHandler {
host: String,
port: u16,
known_hosts_path: PathBuf,
}
#[async_trait]
impl Handler for ClientHandler {
type Error = russh::Error;
async fn check_server_key(
&mut self,
server_public_key: &PublicKey,
) -> Result<bool, Self::Error> {
match crate::known_hosts::check_or_trust_in(
&self.host,
self.port,
server_public_key,
&self.known_hosts_path,
) {
Ok(crate::known_hosts::HostKeyStatus::Verified) => Ok(true),
Ok(crate::known_hosts::HostKeyStatus::Added) => {
tracing::info!(
"trusted new SSH host key for {}:{}",
self.host,
self.port
);
Ok(true)
}
Ok(crate::known_hosts::HostKeyStatus::Changed) => {
tracing::warn!(
"SSH host key mismatch for {}:{}; rejecting connection",
self.host,
self.port
);
Ok(false)
}
Err(err) => {
tracing::warn!(
"known_hosts check failed for {}:{}: {err:#}",
self.host,
self.port
);
Ok(false)
}
}
}
async fn data(
&mut self,
_channel: ChannelId,
_data: &[u8],
_session: &mut client::Session,
) -> Result<(), Self::Error> {
Ok(())
}
}
// Marker trait impl so `Arc<Handle<Handler>>` is nameable in external code.
#[allow(dead_code)]
fn _assert_handle_send() {
fn takes<T: Send>() {}
takes::<Handle<ClientHandler>>();
}
#[cfg(test)]
mod monitor_hardening_tests {
use super::{parse_df_line, parse_monitor_block, parse_ps_line, parse_uptime_secs};
use std::collections::HashMap;
use std::time::Instant;
#[test]
fn df_line_saturates_instead_of_overflowing() {
// avail/total near u64::MAX must not panic on the KiB->bytes multiply.
let line = "/dev/sda1 18446744073709551615 0 18446744073709551615 100% /";
let (_, avail, total) = parse_df_line(line).expect("parses");
assert_eq!(avail, u64::MAX);
assert_eq!(total, u64::MAX);
}
#[test]
fn cpu_overflow_values_do_not_panic() {
let big = u64::MAX;
let block = format!(
"cpu {big} {big} {big} {big} {big}\nMemTotal: 1000 kB\nMemAvailable: 500 kB"
);
let mut prev = None;
let mut prev_net = HashMap::new();
let mut at = Instant::now();
// Must not panic; with no baseline the first sample reports 0% CPU.
assert!(parse_monitor_block(&block, &mut prev, &mut prev_net, &mut at).is_some());
}
#[test]
fn floods_of_fake_interfaces_are_capped() {
let mut block = String::from("MemTotal: 1000 kB\nMemAvailable: 500 kB\n");
for i in 0..500 {
block.push_str(&format!("eth{i}: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16\n"));
}
let mut prev = None;
let mut prev_net = HashMap::new();
let mut at = Instant::now();
assert!(parse_monitor_block(&block, &mut prev, &mut prev_net, &mut at).is_some());
// The remembered interface set is capped, not 500.
assert!(prev_net.len() <= 64, "prev_net held {}", prev_net.len());
}
#[test]
fn parses_proc_uptime_seconds() {
assert_eq!(parse_uptime_secs("12345.67 89012.34"), 12345);
assert_eq!(parse_uptime_secs("not-a-number"), 0);
}
#[test]
fn parses_ps_resource_line() {
let (mem, cpu, name) = parse_ps_line("204800 15.5 docker").expect("parses ps");
assert_eq!(mem, 204800 * 1024);
assert_eq!(cpu, 15.5);
assert_eq!(name, "docker");
}
#[test]
fn monitor_processes_are_sorted_by_highest_cpu_then_memory() {
let block = "\
cpu 100 0 0 900 0
MemTotal: 1000 kB
MemAvailable: 500 kB
__PS__
999999 1.0 memory-heavy
2048 90.0 cpu-heavy
4096 90.0 cpu-heavy-more-memory
__DF__
";
let mut prev = None;
let mut prev_net = HashMap::new();
let mut at = Instant::now();
let event = parse_monitor_block(block, &mut prev, &mut prev_net, &mut at)
.expect("resource stats");
match event {
super::SessionEvent::ResourceStats { processes, .. } => {
assert_eq!(processes[0].2, "cpu-heavy-more-memory");
assert_eq!(processes[1].2, "cpu-heavy");
assert_eq!(processes[2].2, "memory-heavy");
}
_ => panic!("unexpected event"),
}
}
}

244
src/ssh_config.rs Normal file
View File

@@ -0,0 +1,244 @@
//! Minimal `~/.ssh/config` parser used to import hosts as meatshell sessions.
//!
//! We only read the handful of fields a session needs — `HostName`, `User`,
//! `Port`, `IdentityFile` — grouped under each concrete `Host` alias. Wildcard
//! patterns (`Host *`) and unsupported directives are ignored; this is a
//! convenience importer, not a full ssh_config implementation.
use std::path::{Path, PathBuf};
/// One importable host parsed from `~/.ssh/config`.
#[derive(Debug, Clone)]
pub struct ImportedHost {
pub alias: String,
pub hostname: String,
pub user: String,
pub port: u16,
pub identity_file: String,
}
/// Parse the user's `~/.ssh/config` (returns empty if it doesn't exist).
pub fn parse_default() -> Vec<ImportedHost> {
let Some(home) = home_dir() else {
return Vec::new();
};
let path = home.join(".ssh").join("config");
match std::fs::read_to_string(&path) {
Ok(text) => parse_str(&text, &home),
Err(_) => Vec::new(),
}
}
fn home_dir() -> Option<PathBuf> {
directories::UserDirs::new().map(|u| u.home_dir().to_path_buf())
}
/// Split a config line into its keyword and value, supporting both
/// `Keyword value` and `Keyword=value`, and stripping surrounding quotes.
fn split_kv(line: &str) -> Option<(String, String)> {
let line = line.trim();
// Separator is the first '=' or run of whitespace.
let (k, v) = if let Some(eq) = line.find('=') {
// Only treat '=' as the separator if it comes before any space.
let sp = line.find(char::is_whitespace).unwrap_or(usize::MAX);
if eq < sp {
(&line[..eq], &line[eq + 1..])
} else {
line.split_once(char::is_whitespace)?
}
} else {
line.split_once(char::is_whitespace)?
};
let v = v.trim().trim_matches('"').trim();
if v.is_empty() {
return None;
}
Some((k.trim().to_ascii_lowercase(), v.to_string()))
}
fn expand_tilde(path: &str, home: &Path) -> String {
if let Some(rest) = path.strip_prefix("~/") {
home.join(rest).to_string_lossy().replace('\\', "/")
} else if path == "~" {
home.to_string_lossy().replace('\\', "/")
} else {
path.replace('\\', "/")
}
}
fn is_concrete(pattern: &str) -> bool {
!pattern.is_empty() && !pattern.contains(['*', '?', '!'])
}
/// Validate a `HostName` before importing it (issue #33).
///
/// Accepts IPv4 / IPv6 literals and DNS-style hostnames; rejects anything with
/// shell metacharacters, whitespace, control characters, scheme prefixes
/// (`http://…`), etc. This stops a malformed or hostile `~/.ssh/config` entry
/// from flowing unchecked into a saved session. Internal IPs are intentionally
/// allowed — they are a legitimate SSH target and can't be told apart by format.
fn is_valid_hostname(s: &str) -> bool {
// IP literals (including private/loopback addresses) are always fine.
if s.parse::<std::net::IpAddr>().is_ok() {
return true;
}
if s.is_empty() || s.len() > 253 {
return false;
}
// DNS-style: dot-separated labels of [A-Za-z0-9_-], no empty or
// hyphen-edged labels.
s.split('.').all(|label| {
let b = label.as_bytes();
!b.is_empty()
&& b.len() <= 63
&& b.iter()
.all(|&c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
&& b[0] != b'-'
&& b[b.len() - 1] != b'-'
})
}
pub fn parse_str(text: &str, home: &Path) -> Vec<ImportedHost> {
let mut hosts: Vec<ImportedHost> = Vec::new();
let mut cur: Option<ImportedHost> = None;
let flush = |cur: &mut Option<ImportedHost>, out: &mut Vec<ImportedHost>| {
if let Some(mut h) = cur.take() {
if h.hostname.is_empty() {
h.hostname = h.alias.clone();
}
// Skip entries whose HostName isn't a valid IP / hostname (issue #33).
if is_valid_hostname(&h.hostname) {
out.push(h);
} else {
tracing::warn!(
alias = %h.alias,
"skipping ~/.ssh/config host with invalid HostName"
);
}
}
};
for raw in text.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, val)) = split_kv(line) else {
continue;
};
match key.as_str() {
"host" => {
flush(&mut cur, &mut hosts);
// Take the first concrete (non-wildcard) alias of the block.
if let Some(alias) = val.split_whitespace().find(|p| is_concrete(p)) {
cur = Some(ImportedHost {
alias: alias.to_string(),
hostname: String::new(),
user: String::new(),
port: 22,
identity_file: String::new(),
});
}
}
"hostname" => {
if let Some(h) = cur.as_mut() {
h.hostname = val;
}
}
"user" => {
if let Some(h) = cur.as_mut() {
h.user = val;
}
}
"port" => {
if let Some(h) = cur.as_mut() {
if let Ok(p) = val.parse::<u16>() {
h.port = p;
}
}
}
"identityfile" => {
if let Some(h) = cur.as_mut() {
if h.identity_file.is_empty() {
h.identity_file = expand_tilde(&val, home);
}
}
}
_ => {}
}
}
flush(&mut cur, &mut hosts);
hosts
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn parses_basic_blocks() {
let cfg = "\
# comment
Host prod web-prod
HostName 10.0.0.5
User deploy
Port 2222
IdentityFile ~/.ssh/id_ed25519
Host *
User nobody
Host alias-only
";
let home = Path::new("/home/me");
let hosts = parse_str(cfg, home);
assert_eq!(hosts.len(), 2);
assert_eq!(hosts[0].alias, "prod");
assert_eq!(hosts[0].hostname, "10.0.0.5");
assert_eq!(hosts[0].user, "deploy");
assert_eq!(hosts[0].port, 2222);
assert!(hosts[0].identity_file.ends_with("/.ssh/id_ed25519"));
// alias-only: hostname falls back to the alias
assert_eq!(hosts[1].alias, "alias-only");
assert_eq!(hosts[1].hostname, "alias-only");
}
#[test]
fn rejects_invalid_hostnames() {
let cfg = "\
Host good
HostName 10.0.0.5
Host ipv6
HostName ::1
Host evil
HostName a;rm -rf /
Host spaced
HostName foo bar
Host scheme
HostName http://x.com
";
let home = Path::new("/home/me");
let aliases: Vec<String> =
parse_str(cfg, home).into_iter().map(|h| h.alias).collect();
assert!(aliases.contains(&"good".to_string()));
assert!(aliases.contains(&"ipv6".to_string()));
assert!(!aliases.contains(&"evil".to_string()));
assert!(!aliases.contains(&"spaced".to_string()));
assert!(!aliases.contains(&"scheme".to_string()));
}
#[test]
fn validates_hostname_formats() {
assert!(is_valid_hostname("192.168.1.1"));
assert!(is_valid_hostname("::1"));
assert!(is_valid_hostname("example.com"));
assert!(is_valid_hostname("my-server_01.internal"));
assert!(!is_valid_hostname("a;rm -rf /"));
assert!(!is_valid_hostname("foo bar"));
assert!(!is_valid_hostname("http://x.com"));
assert!(!is_valid_hostname("-bad.com"));
assert!(!is_valid_hostname(""));
}
}

53
src/system.rs Normal file
View File

@@ -0,0 +1,53 @@
//! Formatting helpers for sidebar resource values.
pub fn format_bytes_short(bytes: u64) -> String {
const UNITS: [&str; 4] = ["B", "K", "M", "G"];
let mut value = bytes as f64;
let mut idx = 0usize;
while value >= 1024.0 && idx < UNITS.len() - 1 {
value /= 1024.0;
idx += 1;
}
if idx == 0 {
format!("{}{}", bytes, UNITS[idx])
} else if value >= 100.0 || value.fract() == 0.0 {
format!("{}{}", value as u64, UNITS[idx])
} else {
format!("{:.1}{}", value, UNITS[idx])
}
}
/// Human-readable memory size from MiB (e.g. `"1.5G/16G"`).
pub fn format_mem_mib(used_mib: u64, total_mib: u64) -> String {
if total_mib >= 1024 {
let used_gib = used_mib as f64 / 1024.0;
let total_gib = total_mib as f64 / 1024.0;
// Drop decimals for large/round values to keep the string short (sidebar is narrow).
let fmt_gib = |g: f64| -> String {
if g >= 100.0 || g.fract() == 0.0 {
format!("{}", g as u64)
} else {
format!("{:.1}", g)
}
};
format!("{}G/{}G", fmt_gib(used_gib), fmt_gib(total_gib))
} else {
format!("{}/{}M", used_mib, total_mib)
}
}
/// Human-readable network throughput (e.g. `"1.2 MB/s"`).
pub fn format_bytes_per_sec(bytes: u64) -> String {
const UNITS: [&str; 4] = ["B/s", "KB/s", "MB/s", "GB/s"];
let mut value = bytes as f64;
let mut idx = 0;
while value >= 1024.0 && idx < UNITS.len() - 1 {
value /= 1024.0;
idx += 1;
}
if idx == 0 {
format!("{} {}", bytes, UNITS[idx])
} else {
format!("{:.1} {}", value, UNITS[idx])
}
}

272
src/telnet.rs Normal file
View File

@@ -0,0 +1,272 @@
//! Telnet session worker (issue #17).
//!
//! Like [`crate::serial`], this mirrors [`crate::ssh::spawn_session`]'s public
//! surface so the terminal UI is reused unchanged. Telnet is just a TCP byte
//! stream with in-band option negotiation (RFC 854/855), so the worker:
//!
//! * strips IAC command sequences out of the data stream before it reaches the
//! terminal,
//! * answers option negotiation with a minimal, conservative policy (let the
//! server echo and run in character mode; advertise our window size),
//! * doubles `0xFF` bytes in user input as the protocol requires,
//! * re-sends NAWS (window size) on every terminal resize.
//!
//! There is no SFTP and no resource monitor — a Telnet console is a raw pipe.
use anyhow::{Context, Result};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
use crate::config::Session;
use crate::i18n::t;
use crate::ssh::{SessionCommand, SessionEvent, SessionHandle};
// Telnet protocol bytes (RFC 854).
const IAC: u8 = 255;
const DONT: u8 = 254;
const DO: u8 = 253;
const WONT: u8 = 252;
const WILL: u8 = 251;
const SB: u8 = 250;
const SE: u8 = 240;
// Options we care about.
const OPT_ECHO: u8 = 1;
const OPT_SGA: u8 = 3; // suppress go-ahead → character-at-a-time mode
const OPT_NAWS: u8 = 31; // negotiate about window size
pub fn spawn_telnet_session(
runtime: &tokio::runtime::Handle,
tab_id: String,
session: Session,
initial_cols: u32,
initial_rows: u32,
) -> (SessionHandle, UnboundedReceiver<SessionEvent>) {
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<SessionCommand>();
let (evt_tx, evt_rx) = mpsc::unbounded_channel::<SessionEvent>();
let evt_for_task = evt_tx.clone();
let join = runtime.spawn(async move {
if let Err(err) =
run_telnet(session, cmd_rx, evt_for_task.clone(), initial_cols, initial_rows).await
{
let _ = evt_for_task.send(SessionEvent::Closed(format!("{err:#}")));
}
});
(
SessionHandle {
tab_id,
commands: cmd_tx,
join,
},
evt_rx,
)
}
/// Incoming-byte parser state for stripping IAC sequences.
enum TnState {
Data,
Iac,
Opt(u8), // saw IAC <DO/DONT/WILL/WONT>, awaiting option byte
Sub, // inside subnegotiation, awaiting IAC
SubIac, // inside subnegotiation, saw IAC (awaiting SE)
}
fn naws_subneg(cols: u32, rows: u32) -> Vec<u8> {
let w = (cols.clamp(1, u16::MAX as u32)) as u16;
let h = (rows.clamp(1, u16::MAX as u32)) as u16;
let mut v = vec![IAC, SB, OPT_NAWS];
// Width / height are 16-bit; any 255 byte inside must be doubled.
for b in [(w >> 8) as u8, (w & 0xff) as u8, (h >> 8) as u8, (h & 0xff) as u8] {
v.push(b);
if b == IAC {
v.push(IAC);
}
}
v.extend_from_slice(&[IAC, SE]);
v
}
async fn run_telnet(
session: Session,
mut commands: UnboundedReceiver<SessionCommand>,
events: UnboundedSender<SessionEvent>,
initial_cols: u32,
initial_rows: u32,
) -> Result<()> {
let host = session.host.trim().to_string();
let port = if session.port == 0 { 23 } else { session.port };
let addr = format!("{host}:{port}");
let _ = events.send(SessionEvent::Status(format!(
"{} {} ...",
t("Telnet 连接中", "Telnet connecting"),
addr
)));
// Direct, or tunnel through a SOCKS5 / HTTP proxy (reuses issue #7 plumbing).
let stream = match crate::proxy::resolve(&session.proxy) {
Some(p) => {
let _ = events.send(SessionEvent::Status(format!(
"{} {}{}",
t("经代理连接", "via proxy"),
crate::proxy::describe(&p),
addr
)));
crate::proxy::connect(&p, &host, port)
.await
.with_context(|| format!("proxy connect to {addr} failed"))?
}
None => TcpStream::connect(&addr)
.await
.with_context(|| format!("connect {addr} failed"))?,
};
let _ = stream.set_nodelay(true);
let _ = events.send(SessionEvent::Connected);
let _ = events.send(SessionEvent::Status(format!(
"{} {}",
t("已连接", "Connected"),
addr
)));
let (mut rd, mut wr) = tokio::io::split(stream);
// Proactively advertise the options we support: we will suppress go-ahead
// and report our window size. Most gear replies DO and starts echoing.
let mut hello = vec![IAC, WILL, OPT_SGA, IAC, WILL, OPT_NAWS];
hello.extend_from_slice(&naws_subneg(initial_cols, initial_rows));
wr.write_all(&hello).await.context("telnet write hello")?;
wr.flush().await.ok();
let mut state = TnState::Data;
let mut buf = [0u8; 4096];
loop {
tokio::select! {
cmd = commands.recv() => {
match cmd {
Some(SessionCommand::RawInput(bytes)) => {
// Never log keystroke bytes — they can be passwords (#15).
tracing::debug!("telnet write len={} bytes", bytes.len());
// Escape IAC (0xFF) in user data per RFC 854.
let mut out = Vec::with_capacity(bytes.len());
for b in bytes {
out.push(b);
if b == IAC { out.push(IAC); }
}
if wr.write_all(&out).await.is_err() {
let _ = events.send(SessionEvent::Closed(
t("写入失败", "write failed").into()));
break;
}
let _ = wr.flush().await;
}
Some(SessionCommand::Resize(cols, rows)) => {
let _ = wr.write_all(&naws_subneg(cols, rows)).await;
let _ = wr.flush().await;
}
Some(SessionCommand::Close) | None => break,
}
}
r = rd.read(&mut buf) => {
match r {
Ok(0) => break, // peer closed
Ok(n) => {
let mut data = Vec::with_capacity(n);
let mut replies: Vec<u8> = Vec::new();
process_incoming(&buf[..n], &mut state, &mut data, &mut replies);
if !replies.is_empty() {
let _ = wr.write_all(&replies).await;
let _ = wr.flush().await;
}
if !data.is_empty() {
let text = String::from_utf8_lossy(&data).into_owned();
let _ = events.send(SessionEvent::Output(text));
}
}
Err(e) => {
let _ = events.send(SessionEvent::Closed(format!(
"{}: {e}", t("读取错误", "read error"))));
break;
}
}
}
}
}
let _ = events.send(SessionEvent::Closed(
t("连接已关闭", "connection closed").into(),
));
Ok(())
}
/// Feed `input` through the IAC state machine: plain bytes accumulate into
/// `data`, and any negotiation responses we owe go into `replies`.
fn process_incoming(input: &[u8], state: &mut TnState, data: &mut Vec<u8>, replies: &mut Vec<u8>) {
for &b in input {
match *state {
TnState::Data => {
if b == IAC {
*state = TnState::Iac;
} else {
data.push(b);
}
}
TnState::Iac => match b {
IAC => {
// Escaped 0xFF literal in the data stream.
data.push(IAC);
*state = TnState::Data;
}
DO | DONT | WILL | WONT => *state = TnState::Opt(b),
SB => *state = TnState::Sub,
// Standalone commands (GA, NOP, DM, …) — ignore.
_ => *state = TnState::Data,
},
TnState::Opt(cmd) => {
respond_negotiation(cmd, b, replies);
*state = TnState::Data;
}
TnState::Sub => {
// Skip subnegotiation payload until IAC SE.
if b == IAC {
*state = TnState::SubIac;
}
}
TnState::SubIac => {
// IAC SE ends the block; IAC IAC is an escaped data byte we drop
// (we don't interpret any subnegotiation payloads).
if b == SE {
*state = TnState::Data;
} else {
*state = TnState::Sub;
}
}
}
}
}
/// Conservative negotiation policy. Returns nothing; appends a 3-byte reply.
fn respond_negotiation(cmd: u8, opt: u8, replies: &mut Vec<u8>) {
match cmd {
// Server asks us to enable an option.
DO => {
let accept = matches!(opt, OPT_SGA | OPT_NAWS);
replies.extend_from_slice(&[IAC, if accept { WILL } else { WONT }, opt]);
}
// Server tells us to disable — always agree.
DONT => replies.extend_from_slice(&[IAC, WONT, opt]),
// Server offers to enable an option on its side.
WILL => {
// Let the server echo and run in character mode; refuse the rest.
let accept = matches!(opt, OPT_ECHO | OPT_SGA);
replies.extend_from_slice(&[IAC, if accept { DO } else { DONT }, opt]);
}
// Server says it won't — acknowledge.
WONT => replies.extend_from_slice(&[IAC, DONT, opt]),
_ => {}
}
}

1608
ui/app.slint Normal file

File diff suppressed because it is too large Load Diff

123
ui/confirm_dialog.slint Normal file
View File

@@ -0,0 +1,123 @@
import { Theme } from "theme.slint";
import { GhostButton } from "widgets.slint";
// A destructive (red) action button, matching PrimaryButton's shape.
component DangerButton inherits Rectangle {
in property <string> text;
callback clicked();
height: 30px;
min-width: 80px;
border-radius: Theme.radius-sm;
background: touch.pressed ? #c44a4a : (touch.has-hover ? #ec6a6a : Theme.danger);
animate background { duration: 120ms; }
HorizontalLayout {
height: parent.height;
padding-left: 14px;
padding-right: 14px;
alignment: center;
Text {
height: parent.height;
text: root.text;
color: white;
font-size: Theme.fs-md;
font-weight: 600;
horizontal-alignment: center;
vertical-alignment: center;
}
}
touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
}
// In-app modal confirmation dialog, styled to match the rest of meatshell
// (a dark card over a dimmed backdrop, like SessionDialog). Purely
// presentational: it emits `confirm` / `cancel` and the caller decides what
// to do. Used for the irreversible SFTP delete (#28).
export component ConfirmDialog inherits Rectangle {
in-out property <bool> is-open: false;
in property <string> title: @tr("Please confirm");
in property <string> message;
in property <string> detail; // optional, e.g. the file path
in property <string> confirm-label: @tr("Confirm");
callback confirm();
callback cancel();
visible: is-open;
background: #000000aa;
// Backdrop click cancels.
TouchArea {
width: 100%;
height: 100%;
clicked => { root.cancel(); }
}
Rectangle {
width: 380px;
height: card.preferred-height;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
background: Theme.bg-panel;
border-radius: Theme.radius-lg;
border-width: 1px;
border-color: Theme.border-strong;
drop-shadow-blur: 24px;
drop-shadow-color: #00000080;
// Swallow clicks inside the card so they don't hit the backdrop.
TouchArea { width: 100%; height: 100%; }
card := VerticalLayout {
padding: 20px;
spacing: 12px;
HorizontalLayout {
spacing: 8px;
Text {
text: "⚠";
color: Theme.danger;
font-size: Theme.fs-xl;
vertical-alignment: center;
}
Text {
text: root.title;
color: Theme.text-primary;
font-size: Theme.fs-xl;
font-weight: 600;
vertical-alignment: center;
}
}
Text {
text: root.message;
color: Theme.text-secondary;
font-size: Theme.fs-md;
wrap: word-wrap;
}
if root.detail != "" : Text {
text: root.detail;
color: Theme.text-primary;
font-family: Theme.font-mono;
font-size: Theme.fs-sm;
wrap: word-wrap;
}
HorizontalLayout {
height: 30px;
alignment: end;
spacing: 8px;
GhostButton {
text: @tr("Cancel");
clicked => { root.cancel(); }
}
DangerButton {
text: root.confirm-label;
clicked => { root.confirm(); }
}
}
}
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

454
ui/session_dialog.slint Normal file
View File

@@ -0,0 +1,454 @@
import { Theme } from "theme.slint";
import { LabeledInput, PrimaryButton, GhostButton } from "widgets.slint";
export struct SessionDraft {
id: string,
name: string,
group: string,
kind: string, // "ssh" | "serial" | "telnet"
host: string,
port: int,
user: string,
auth: string, // "password" | "key"
password: string,
private-key-path: string,
proxy: string, // "" | "socks5://host:port" | "http://host:port"
// Serial-only (ignored unless kind == "serial")
serial-port: string,
baud-rate: int,
data-bits: int,
stop-bits: int,
parity: string, // "none" | "odd" | "even"
flow-control: string, // "none" | "hardware" | "software"
}
// A single segment in a segmented selector (auth toggle, baud presets, …).
component SegOption inherits Rectangle {
in property <string> label;
in property <bool> active;
callback clicked();
height: 30px;
horizontal-stretch: 1;
border-radius: Theme.radius-sm;
border-width: 1px;
background: active ? Theme.accent : Theme.bg-root;
border-color: active ? Theme.accent : Theme.border-subtle;
Text {
width: parent.width;
height: parent.height;
text: root.label;
color: active ? white : Theme.text-primary;
font-size: Theme.fs-md;
horizontal-alignment: center;
vertical-alignment: center;
}
TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
}
component FolderOption inherits Rectangle {
in property <string> label;
in property <bool> active;
callback clicked();
height: 30px;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: active ? Theme.accent : Theme.border-subtle;
background: active ? Theme.bg-active : Theme.bg-root;
HorizontalLayout {
height: parent.height;
padding-left: 10px;
padding-right: 10px;
spacing: 8px;
Text {
width: 18px;
height: parent.height;
text: "\u{E2C7}";
font-family: "Material Icons";
color: active ? Theme.accent : Theme.text-muted;
font-size: Theme.fs-sm;
horizontal-alignment: center;
vertical-alignment: center;
}
Text {
height: parent.height;
text: root.label;
color: Theme.text-primary;
font-size: Theme.fs-md;
vertical-alignment: center;
horizontal-stretch: 1;
overflow: elide;
}
if active : Text {
width: 18px;
height: parent.height;
text: "\u{E5CA}";
font-family: "Material Icons";
color: Theme.accent;
font-size: Theme.fs-sm;
horizontal-alignment: center;
vertical-alignment: center;
}
}
TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
}
// Modal dialog for creating / editing a session (SSH / Serial / Telnet).
// The dialog is purely presentational — it stores values in mutable
// properties and emits `submit` / `cancel` callbacks up to the main window.
export component SessionDialog inherits Rectangle {
in-out property <bool> is-open: false;
in-out property <bool> is-editing: false;
in-out property <string> draft-id;
in-out property <string> draft-name;
in-out property <string> draft-group: "Default";
in-out property <string> draft-kind: "ssh";
in-out property <string> draft-host;
in-out property <string> draft-port: "22";
in-out property <string> draft-user: "root";
in-out property <string> draft-auth: "password";
in-out property <string> draft-password;
in-out property <string> draft-key-path;
in-out property <string> draft-proxy;
in-out property <string> draft-serial-port;
in-out property <string> draft-baud: "115200";
in-out property <string> draft-data-bits: "8";
in-out property <string> draft-stop-bits: "1";
in-out property <string> draft-parity: "none";
in-out property <string> draft-flow: "none";
in property <[string]> folders;
callback submit(SessionDraft);
callback cancel();
callback pick-key-file(); // open a native file picker for the private key
visible: is-open;
background: #000000aa;
// Swallow clicks on the backdrop
TouchArea {
width: 100%; height: 100%;
clicked => { root.cancel(); }
}
Rectangle {
width: min(440px, parent.width - 48px);
height: min(620px, parent.height - 48px);
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
background: Theme.bg-panel;
border-radius: Theme.radius-lg;
border-width: 1px;
border-color: Theme.border-strong;
drop-shadow-blur: 24px;
drop-shadow-color: #00000080;
// Prevent backdrop click from propagating when user clicks inside the card
TouchArea {
width: 100%; height: 100%;
}
VerticalLayout {
padding: 20px;
spacing: 12px;
Text {
text: is-editing ? @tr("Edit session") : @tr("New session");
color: Theme.text-primary;
font-size: Theme.fs-xl;
font-weight: 600;
}
Flickable {
vertical-stretch: 1;
viewport-width: self.width;
viewport-height: max(self.height, form-col.preferred-height);
form-col := VerticalLayout {
width: parent.width;
alignment: start;
spacing: 12px;
// Connection type selector (always shown).
VerticalLayout {
spacing: 4px;
Text {
text: @tr("Connection type");
color: Theme.text-secondary;
font-size: Theme.fs-sm;
}
HorizontalLayout {
height: 30px;
spacing: 6px;
SegOption {
label: "SSH";
active: root.draft-kind == "ssh";
clicked => { root.draft-kind = "ssh"; }
}
SegOption {
label: @tr("Serial");
active: root.draft-kind == "serial";
clicked => { root.draft-kind = "serial"; }
}
SegOption {
label: "Telnet";
active: root.draft-kind == "telnet";
clicked => { root.draft-kind = "telnet"; }
}
}
}
LabeledInput {
label: @tr("Name");
placeholder: @tr("e.g. production web-01");
value <=> root.draft-name;
}
VerticalLayout {
spacing: 4px;
Text {
text: @tr("Folder");
color: Theme.text-secondary;
font-size: Theme.fs-sm;
}
VerticalLayout {
spacing: 6px;
for folder in root.folders : FolderOption {
label: folder;
active: root.draft-group == folder;
clicked => { root.draft-group = folder; }
}
}
}
// ── SSH / Telnet share host + port ──────────────────────────────
if root.draft-kind != "serial" : HorizontalLayout {
spacing: 10px;
LabeledInput {
label: @tr("Host / IP");
placeholder: "192.168.1.10";
value <=> root.draft-host;
horizontal-stretch: 2;
}
LabeledInput {
label: @tr("Port");
placeholder: root.draft-kind == "telnet" ? "23" : "22";
value <=> root.draft-port;
horizontal-stretch: 1;
}
}
// ── SSH-only: user + auth ───────────────────────────────────────
if root.draft-kind == "ssh" : LabeledInput {
label: @tr("Username");
placeholder: "root";
value <=> root.draft-user;
}
if root.draft-kind == "ssh" : VerticalLayout {
spacing: 4px;
Text {
text: @tr("Authentication");
color: Theme.text-secondary;
font-size: Theme.fs-sm;
}
HorizontalLayout {
height: 30px;
spacing: 6px;
SegOption {
label: @tr("Password");
active: root.draft-auth == "password";
clicked => { root.draft-auth = "password"; }
}
SegOption {
label: @tr("Private key");
active: root.draft-auth == "key";
clicked => { root.draft-auth = "key"; }
}
}
}
if root.draft-kind == "ssh" && root.draft-auth == "password" : LabeledInput {
label: @tr("Password");
placeholder: root.is-editing ? @tr("Leave blank to keep the current password") : "••••••••";
password: true;
value <=> root.draft-password;
}
if root.draft-kind == "ssh" && root.draft-auth == "key" : VerticalLayout {
spacing: 4px;
Text {
text: @tr("Private key file");
color: Theme.text-secondary;
font-size: Theme.fs-sm;
}
HorizontalLayout {
height: 32px;
spacing: 8px;
Rectangle {
height: 32px;
horizontal-stretch: 1;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: keyin.has-focus ? Theme.accent : Theme.border-subtle;
background: Theme.bg-root;
keyin := TextInput {
x: 10px; y: 0px;
width: parent.width - 20px;
height: parent.height;
text <=> root.draft-key-path;
color: Theme.text-primary;
font-size: Theme.fs-md;
vertical-alignment: center;
single-line: true;
}
if root.draft-key-path == "" : Text {
x: 10px; y: 0px;
height: parent.height;
text: "/home/you/.ssh/id_ed25519";
color: Theme.text-muted;
font-size: Theme.fs-md;
vertical-alignment: center;
}
}
Rectangle {
width: 64px; height: 32px;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: Theme.border-subtle;
background: browse-ta.has-hover ? Theme.bg-hover : Theme.bg-elevated;
Text {
width: parent.width;
height: parent.height;
text: @tr("Browse…");
color: Theme.text-primary;
font-size: Theme.fs-sm;
horizontal-alignment: center;
vertical-alignment: center;
}
browse-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.pick-key-file(); }
}
}
}
Text {
text: @tr("Pick the private key itself (not the .pub public key)");
color: Theme.text-muted;
font-size: Theme.fs-xs;
}
}
// ── Serial-only fields ──────────────────────────────────────────
if root.draft-kind == "serial" : LabeledInput {
label: @tr("Serial port");
placeholder: "COM3 / /dev/ttyUSB0";
value <=> root.draft-serial-port;
}
if root.draft-kind == "serial" : LabeledInput {
label: @tr("Baud rate");
placeholder: "115200";
value <=> root.draft-baud;
}
if root.draft-kind == "serial" : VerticalLayout {
spacing: 4px;
Text {
text: @tr("Data bits / Stop bits / Parity");
color: Theme.text-secondary;
font-size: Theme.fs-sm;
}
HorizontalLayout {
height: 30px;
spacing: 6px;
SegOption { label: "7"; active: root.draft-data-bits == "7"; clicked => { root.draft-data-bits = "7"; } }
SegOption { label: "8"; active: root.draft-data-bits == "8"; clicked => { root.draft-data-bits = "8"; } }
Rectangle { width: 8px; }
SegOption { label: "1"; active: root.draft-stop-bits == "1"; clicked => { root.draft-stop-bits = "1"; } }
SegOption { label: "2"; active: root.draft-stop-bits == "2"; clicked => { root.draft-stop-bits = "2"; } }
Rectangle { width: 8px; }
SegOption { label: "N"; active: root.draft-parity == "none"; clicked => { root.draft-parity = "none"; } }
SegOption { label: "O"; active: root.draft-parity == "odd"; clicked => { root.draft-parity = "odd"; } }
SegOption { label: "E"; active: root.draft-parity == "even"; clicked => { root.draft-parity = "even"; } }
}
}
if root.draft-kind == "serial" : VerticalLayout {
spacing: 4px;
Text {
text: @tr("Flow control");
color: Theme.text-secondary;
font-size: Theme.fs-sm;
}
HorizontalLayout {
height: 30px;
spacing: 6px;
SegOption { label: @tr("None"); active: root.draft-flow == "none"; clicked => { root.draft-flow = "none"; } }
SegOption { label: @tr("Hardware"); active: root.draft-flow == "hardware"; clicked => { root.draft-flow = "hardware"; } }
SegOption { label: @tr("Software"); active: root.draft-flow == "software"; clicked => { root.draft-flow = "software"; } }
}
}
// ── Optional outbound proxy (SSH / Telnet only) ─────────────────
if root.draft-kind != "serial" : VerticalLayout {
spacing: 4px;
LabeledInput {
label: @tr("Proxy (optional)");
placeholder: "socks5://127.0.0.1:1080";
value <=> root.draft-proxy;
}
Text {
text: @tr("Leave blank to use $ALL_PROXY or connect directly");
color: Theme.text-muted;
font-size: Theme.fs-xs;
}
}
}
}
HorizontalLayout {
height: 30px;
alignment: end;
spacing: 8px;
GhostButton {
text: @tr("Cancel");
clicked => { root.cancel(); }
}
PrimaryButton {
text: is-editing ? @tr("Save") : @tr("Create");
clicked => {
root.submit({
id: root.draft-id,
name: root.draft-name,
group: root.draft-group,
kind: root.draft-kind,
host: root.draft-host,
port: root.draft-port.to-float(),
user: root.draft-user,
auth: root.draft-auth,
password: root.draft-password,
private-key-path: root.draft-key-path,
proxy: root.draft-proxy,
serial-port: root.draft-serial-port,
baud-rate: root.draft-baud.to-float(),
data-bits: root.draft-data-bits.to-float(),
stop-bits: root.draft-stop-bits.to-float(),
parity: root.draft-parity,
flow-control: root.draft-flow,
});
}
}
}
}
}
}

591
ui/sftp_panel.slint Normal file
View File

@@ -0,0 +1,591 @@
import { Theme } from "theme.slint";
import { ScrollView } from "std-widgets.slint";
// ---------------------------------------------------------------------------
// Data types
// ---------------------------------------------------------------------------
export struct SftpEntry {
name: string,
full-path: string,
is-dir: bool,
size: string,
modified: string,
}
export struct SftpTreeNode {
path: string,
name: string,
depth: int,
expanded: bool,
has-children: bool,
}
// One row of the file-list right-click context menu.
component SftpMenuItem inherits Rectangle {
in property <string> label;
in property <brush> tint: Theme.text-primary;
callback clicked();
height: 26px;
border-radius: Theme.radius-sm;
background: ta.has-hover ? Theme.bg-hover : transparent;
ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
Text {
x: 10px;
text: root.label;
color: root.tint;
font-size: Theme.fs-sm;
vertical-alignment: center;
height: parent.height;
}
}
// ---------------------------------------------------------------------------
// SftpPanel — left directory tree + right file listing
// ---------------------------------------------------------------------------
export component SftpPanel inherits Rectangle {
// --- Properties --------------------------------------------------------
in property <string> current-path: "/";
in property <[SftpEntry]> entries;
in property <string> status: @tr("SFTP not connected");
in property <bool> loading: false;
in property <[SftpTreeNode]> tree-nodes: [];
in property <bool> app-background-active: false;
in property <float> ui-opacity: 0.72;
in-out property <string> path-draft: "/";
in property <length> file-font-size: 16px;
in-out property <length> name-column-width: 180px;
in-out property <bool> name-column-manual: false;
property <bool> rename-open: false;
property <string> rename-path;
property <string> rename-draft;
property <bool> name-column-dragging: false;
property <length> name-column-drag-start-x: 0px;
// --- Callbacks ---------------------------------------------------------
callback navigate(string);
callback download(string);
callback upload-clicked(string);
callback refresh(string);
callback tree-expand(string); // toggle expand/collapse + navigate
callback delete(string); // remove a remote file/dir
callback rename(string, string);
callback edit(string); // download to temp + open + auto-reupload
callback name-column-width-changed(float);
callback name-column-auto();
// -----------------------------------------------------------------------
background: root.app-background-active ? Theme.bg-panel.with-alpha(root.ui-opacity) : Theme.bg-panel;
changed current-path => { root.path-draft = root.current-path; }
VerticalLayout {
spacing: 0;
// --- Toolbar (full width) ------------------------------------------
Rectangle {
height: 30px;
background: root.app-background-active ? Theme.bg-panel-alt.with-alpha(root.ui-opacity) : Theme.bg-panel-alt;
HorizontalLayout {
height: parent.height;
padding-left: 6px;
padding-right: 6px;
spacing: 4px;
// Editable path bar fills the left; action buttons sit on the right.
Rectangle {
y: (parent.height - self.height) / 2;
horizontal-stretch: 1;
height: 22px;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: path-input.has-focus ? Theme.accent : Theme.border-subtle;
background: root.app-background-active ? Theme.bg-root.with-alpha(root.ui-opacity) : Theme.bg-root;
path-input := TextInput {
x: 8px;
width: parent.width - 16px;
height: parent.height;
text <=> root.path-draft;
color: Theme.text-primary;
font-size: root.file-font-size;
single-line: true;
vertical-alignment: center;
key-pressed(e) => {
if (e.text == "\n") {
root.navigate(self.text);
accept
} else {
reject
}
}
}
}
Rectangle {
y: (parent.height - self.height) / 2;
width: 24px; height: 22px;
border-radius: Theme.radius-sm;
background: go-ta.has-hover ? Theme.bg-hover : transparent;
go-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.navigate(root.path-draft); }
}
Text { text: "\u{E5C8}"; font-family: "Material Icons";
color: Theme.text-secondary; font-size: Theme.fs-sm;
width: parent.width; height: parent.height;
horizontal-alignment: center; vertical-alignment: center; }
}
up-btn := Rectangle {
y: (parent.height - self.height) / 2;
width: 24px; height: 22px;
border-radius: Theme.radius-sm;
background: up-ta.has-hover ? Theme.bg-hover : transparent;
up-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.navigate(".."); }
}
Text { text: "↑"; color: Theme.text-secondary; font-size: Theme.fs-md;
width: parent.width; height: parent.height;
horizontal-alignment: center; vertical-alignment: center; }
}
Rectangle {
y: (parent.height - self.height) / 2;
width: 52px; height: 22px;
border-radius: Theme.radius-sm;
background: ul-ta.has-hover ? Theme.accent-hover : Theme.accent;
ul-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.upload-clicked(root.current-path); }
}
Text { text: @tr("Upload"); color: #ffffff; font-size: Theme.fs-sm;
width: parent.width; height: parent.height;
horizontal-alignment: center; vertical-alignment: center; }
}
Rectangle {
y: (parent.height - self.height) / 2;
width: 24px; height: 22px;
border-radius: Theme.radius-sm;
background: ref-ta.has-hover ? Theme.bg-hover : transparent;
ref-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.refresh(root.current-path); }
}
Text { text: "\u{E5D5}"; font-family: "Material Icons";
color: Theme.text-secondary; font-size: Theme.fs-sm;
width: parent.width; height: parent.height;
horizontal-alignment: center; vertical-alignment: center; }
}
}
}
// --- Main area: tree (left) + file list (right) --------------------
HorizontalLayout {
vertical-stretch: 1;
spacing: 0;
// ── Left: directory tree ────────────────────────────────────────
VerticalLayout {
width: 160px;
spacing: 0;
// Tree header
Rectangle {
height: 20px;
background: root.app-background-active ? Theme.bg-root.with-alpha(root.ui-opacity) : Theme.bg-root;
Text {
x: 8px;
text: @tr("Directory tree");
color: Theme.text-muted;
font-size: max(10px, root.file-font-size - 2px);
vertical-alignment: center;
height: parent.height;
}
}
Rectangle { height: 1px; background: Theme.border-subtle; }
// Tree list with scrollbar
ScrollView {
vertical-stretch: 1;
tree-list := VerticalLayout {
spacing: 0;
for node[i] in root.tree-nodes : Rectangle {
height: max(24px, root.file-font-size + 10px);
background: node-ta.has-hover
? Theme.bg-hover
: (node.path == root.current-path
? #4a90e230
: (mod(i, 2) == 0 ? transparent : #ffffff08));
node-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.tree-expand(node.path); }
}
HorizontalLayout {
height: parent.height;
spacing: 0;
// Depth indent spacer
Rectangle { width: node.depth * 12px; }
// Expand/collapse arrow
Text {
width: 14px;
text: node.has-children
? (node.expanded ? "▼" : "▶")
: " ";
color: Theme.text-muted;
font-size: max(9px, root.file-font-size - 3px);
horizontal-alignment: center;
vertical-alignment: center;
}
// Folder name
Text {
text: node.depth == 0 ? "/" : node.name;
color: node.path == root.current-path
? Theme.accent
: Theme.text-primary;
font-size: root.file-font-size;
horizontal-stretch: 1;
overflow: elide;
vertical-alignment: center;
}
}
}
}
}
}
// Vertical separator
Rectangle { width: 1px; background: Theme.border-subtle; }
// ── Right: file listing ─────────────────────────────────────────
right-pane := VerticalLayout {
horizontal-stretch: 1;
spacing: 0;
// Column headers
Rectangle {
height: 20px;
background: root.app-background-active ? Theme.bg-root.with-alpha(root.ui-opacity) : Theme.bg-root;
HorizontalLayout {
height: parent.height;
padding-left: 8px; padding-right: 8px;
Text { text: @tr("Name"); color: Theme.text-muted; font-size: max(10px, root.file-font-size - 2px);
width: clamp(root.name-column-width, 96px, 360px); vertical-alignment: center; }
Rectangle {
width: 6px;
height: parent.height;
background: root.name-column-dragging || name-resize-ta.has-hover ? Theme.accent.with-alpha(0.45) : transparent;
name-resize-ta := TouchArea {
mouse-cursor: col-resize;
pointer-event(ev) => {
if (ev.kind == PointerEventKind.down) {
root.name-column-dragging = true;
root.name-column-drag-start-x = self.mouse-x;
root.name-column-manual = true;
} else if (ev.kind == PointerEventKind.up) {
root.name-column-dragging = false;
root.name-column-width-changed(root.name-column-width / 1px);
}
}
moved => {
if (root.name-column-dragging) {
root.name-column-width = clamp(
root.name-column-width + self.mouse-x - root.name-column-drag-start-x,
96px,
360px
);
root.name-column-drag-start-x = self.mouse-x;
}
}
}
}
if root.name-column-manual : Rectangle {
y: (parent.height - self.height) / 2;
width: 42px;
height: 18px;
border-radius: Theme.radius-sm;
background: name-auto-ta.has-hover ? Theme.bg-hover : transparent;
name-auto-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.name-column-manual = false;
root.name-column-auto();
root.refresh(root.current-path);
}
}
Text {
width: parent.width;
height: parent.height;
text: @tr("Auto");
color: Theme.accent;
font-size: max(10px, root.file-font-size - 3px);
horizontal-alignment: center;
vertical-alignment: center;
}
}
Rectangle { horizontal-stretch: 1; }
Text { text: @tr("Size"); color: Theme.text-muted; font-size: max(10px, root.file-font-size - 2px);
width: 72px; horizontal-alignment: right; vertical-alignment: center; }
Text { text: @tr("Modified"); color: Theme.text-muted; font-size: max(10px, root.file-font-size - 2px);
width: 116px; horizontal-alignment: right; vertical-alignment: center; }
}
}
Rectangle { height: 1px; background: Theme.border-subtle; }
// File list with scrollbar
ScrollView {
vertical-stretch: 1;
file-list := VerticalLayout {
spacing: 0;
if root.loading : Rectangle {
height: 40px;
Text { width: parent.width; height: parent.height;
text: @tr("Loading..."); color: Theme.text-muted; font-size: root.file-font-size;
horizontal-alignment: center; vertical-alignment: center; }
}
if !root.loading && root.entries.length == 0 : Rectangle {
height: 40px;
Text { width: parent.width; height: parent.height;
text: @tr("Empty directory"); color: Theme.text-muted; font-size: root.file-font-size;
horizontal-alignment: center; vertical-alignment: center; }
}
for entry[i] in root.entries : row := Rectangle {
height: max(26px, root.file-font-size + 12px);
background: row-ta.has-hover
? #4a90e225
: (mod(i, 2) == 0 ? transparent : #ffffff08);
// Right-click anchor (relative to this row).
property <length> mx;
property <length> my;
row-ta := TouchArea {
mouse-cursor: pointer;
double-clicked => {
if (entry.is-dir) {
root.navigate(entry.full-path);
} else {
root.edit(entry.full-path);
}
}
pointer-event(ev) => {
if (ev.kind == PointerEventKind.down
&& ev.button == PointerEventButton.right) {
row.mx = self.mouse-x;
row.my = self.mouse-y;
row-menu.show();
}
}
}
// Right-click context menu.
row-menu := PopupWindow {
x: row.mx;
y: row.my;
width: 108px;
height: (entry.is-dir ? 2 : 4) * 27px + 8px;
Rectangle {
background: Theme.bg-panel;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: Theme.border-strong;
drop-shadow-blur: 12px;
drop-shadow-color: #00000060;
VerticalLayout {
padding: 4px;
spacing: 1px;
if !entry.is-dir : SftpMenuItem {
label: @tr("Download");
clicked => { root.download(entry.full-path); }
}
SftpMenuItem {
label: @tr("Rename");
clicked => {
root.rename-path = entry.full-path;
root.rename-draft = entry.name;
root.rename-open = true;
}
}
if !entry.is-dir : SftpMenuItem {
label: @tr("Edit");
clicked => { root.edit(entry.full-path); }
}
SftpMenuItem {
label: @tr("Delete");
tint: #d86c6c;
clicked => { root.delete(entry.full-path); }
}
}
}
}
HorizontalLayout {
height: parent.height;
padding-left: 8px; padding-right: 8px; spacing: 4px;
Text {
text: entry.is-dir ? "📁 " + entry.name : "📄 " + entry.name;
color: entry.is-dir ? Theme.accent : Theme.text-primary;
font-size: root.file-font-size;
width: clamp(root.name-column-width, 96px, 360px);
overflow: elide;
vertical-alignment: center;
}
Rectangle { width: 6px; height: parent.height; }
Rectangle { horizontal-stretch: 1; }
Text {
text: entry.is-dir ? "" : entry.size;
color: Theme.text-secondary; font-size: max(10px, root.file-font-size - 2px);
width: 72px; horizontal-alignment: right; vertical-alignment: center;
}
Text {
text: entry.modified;
color: Theme.text-muted; font-size: max(10px, root.file-font-size - 2px);
width: 116px; horizontal-alignment: right; vertical-alignment: center;
}
}
}
}
}
}
}
if root.rename-open : Rectangle {
width: parent.width;
height: parent.height;
background: #00000040;
TouchArea { clicked => { root.rename-open = false; } }
Rectangle {
x: (parent.width - self.width) / 2;
y: 40px;
width: min(340px, parent.width - 32px);
height: 118px;
border-radius: Theme.radius-md;
border-width: 1px;
border-color: Theme.border-strong;
background: Theme.bg-panel;
drop-shadow-blur: 16px;
drop-shadow-color: #00000080;
TouchArea {}
VerticalLayout {
padding: 12px;
spacing: 8px;
Text {
text: @tr("Rename");
color: Theme.text-primary;
font-size: Theme.fs-sm;
font-weight: 600;
}
Rectangle {
height: 30px;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: rename-input.has-focus ? Theme.accent : Theme.border-subtle;
background: Theme.bg-root;
rename-input := TextInput {
x: 8px;
width: parent.width - 16px;
height: parent.height;
text <=> root.rename-draft;
color: Theme.text-primary;
font-size: Theme.fs-sm;
single-line: true;
vertical-alignment: center;
key-pressed(e) => {
if (e.text == "\n") {
root.rename(root.rename-path, root.rename-draft);
root.rename-open = false;
accept
} else {
reject
}
}
}
}
HorizontalLayout {
height: 28px;
spacing: 8px;
Rectangle { horizontal-stretch: 1; }
Rectangle {
width: 68px;
height: parent.height;
border-radius: Theme.radius-sm;
background: rn-cancel-ta.has-hover ? Theme.bg-hover : Theme.bg-elevated;
rn-cancel-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.rename-open = false; }
}
Text {
width: parent.width;
height: parent.height;
text: @tr("Cancel");
color: Theme.text-primary;
font-size: Theme.fs-sm;
horizontal-alignment: center;
vertical-alignment: center;
}
}
Rectangle {
width: 68px;
height: parent.height;
border-radius: Theme.radius-sm;
background: rn-ok-ta.has-hover ? Theme.accent-hover : Theme.accent;
rn-ok-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.rename(root.rename-path, root.rename-draft);
root.rename-open = false;
}
}
Text {
width: parent.width;
height: parent.height;
text: @tr("Save");
color: #ffffff;
font-size: Theme.fs-sm;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
}
}
// --- Status bar (full width) --------------------------------------
Rectangle {
height: 18px;
background: root.app-background-active ? Theme.bg-panel-alt.with-alpha(root.ui-opacity) : Theme.bg-panel-alt;
Text {
x: 8px;
text: root.status;
color: Theme.text-muted;
font-size: Theme.fs-xs;
vertical-alignment: center;
height: parent.height;
overflow: elide;
width: parent.width - 16px;
}
}
}
}

499
ui/sidebar.slint Normal file
View File

@@ -0,0 +1,499 @@
import { Theme } from "theme.slint";
import { Sparkline } from "widgets.slint";
// One filesystem row for the disk-usage panel.
export struct DiskInfo {
path: string,
detail: string, // "available/total"
percent: float, // used fraction 0.0..1.0
}
export struct ProcessInfo {
mem: string,
cpu: string,
name: string,
}
// --- StatRow ---------------------------------------------------------------
// A single label / percentage / progress-bar row used for CPU / memory / swap.
component StatRow inherits Rectangle {
in property <string> label;
in property <float> percent; // 0.0 .. 1.0
in property <string> detail;
in property <brush> bar-color: Theme.accent;
height: 30px;
border-radius: Theme.radius-sm;
background: Theme.bg-panel-alt.with-alpha(0.38);
HorizontalLayout {
height: parent.height;
padding-left: 6px;
padding-right: 6px;
spacing: 7px;
Text {
width: 32px;
height: parent.height;
text: label;
color: Theme.text-secondary;
font-size: Theme.fs-xs;
vertical-alignment: center;
overflow: elide;
}
Rectangle {
y: (parent.height - self.height) / 2;
height: 20px;
horizontal-stretch: 1;
background: Theme.bg-root.with-alpha(0.56);
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: Theme.border-subtle.with-alpha(0.58);
clip: true;
Rectangle {
x: 0;
height: parent.height;
width: parent.width * clamp(percent, 0.0, 1.0);
background: bar-color.with-alpha(0.52);
border-radius: Theme.radius-sm;
animate width { duration: 500ms; easing: ease-out; }
}
HorizontalLayout {
height: parent.height;
padding-left: 7px;
padding-right: 7px;
Text {
height: parent.height;
text: Math.round(percent * 100) + "%";
color: Theme.text-primary;
font-size: Theme.fs-xs;
font-weight: 600;
vertical-alignment: center;
}
Rectangle { horizontal-stretch: 1; }
Text {
height: parent.height;
text: detail;
color: Theme.text-primary;
font-size: Theme.fs-xs;
horizontal-alignment: right;
vertical-alignment: center;
overflow: elide;
}
}
}
}
}
component ProcessTable inherits VerticalLayout {
in property <[ProcessInfo]> processes;
vertical-stretch: 0;
spacing: 1px;
HorizontalLayout {
height: 18px;
padding-left: 6px;
padding-right: 6px;
spacing: 4px;
Text {
width: 42px;
text: @tr("Memory");
color: Theme.text-muted;
font-size: Theme.fs-xs;
vertical-alignment: center;
}
Text {
width: 32px;
text: "CPU";
color: Theme.text-muted;
font-size: Theme.fs-xs;
vertical-alignment: center;
}
Text {
text: @tr("Program");
color: Theme.text-muted;
font-size: Theme.fs-xs;
vertical-alignment: center;
horizontal-stretch: 1;
}
}
for p in root.processes : Rectangle {
height: 18px;
border-radius: 2px;
background: row-ta.has-hover ? Theme.bg-hover.with-alpha(0.78) : Theme.bg-panel-alt.with-alpha(0.22);
row-ta := TouchArea {}
HorizontalLayout {
height: parent.height;
padding-left: 6px;
padding-right: 6px;
spacing: 4px;
Text {
width: 42px;
text: p.mem;
color: Theme.text-secondary;
font-size: Theme.fs-xs;
vertical-alignment: center;
overflow: elide;
}
Text {
width: 32px;
text: p.cpu;
color: Theme.text-secondary;
font-size: Theme.fs-xs;
vertical-alignment: center;
overflow: elide;
}
Text {
text: p.name;
color: Theme.text-primary;
font-size: Theme.fs-xs;
vertical-alignment: center;
horizontal-stretch: 1;
overflow: elide;
}
}
}
}
// --- NetGraph --------------------------------------------------------------
// One network throughput graph: a header row (↑ tx / ↓ rx, plus an optional
// NIC selector dropdown) over an auto-scaled sparkline.
component NetGraph inherits VerticalLayout {
in property <string> up-text;
in property <string> down-text;
in property <[float]> history;
in property <bool> show-selector: false;
in property <[string]> ifaces;
in property <string> selected;
callback iface-selected(string);
// Stay at preferred height; without this the inherited VerticalLayout grabs
// a stretch share of the sidebar and balloons the sparkline into blank space.
vertical-stretch: 0;
spacing: 3px;
HorizontalLayout {
height: 18px;
spacing: 3px;
Text { // ↑ upload (green)
width: 12px;
height: parent.height;
text: "\u{E5D8}";
font-family: "Material Icons";
color: Theme.success;
font-size: Theme.fs-xs;
horizontal-alignment: center;
vertical-alignment: center;
}
Text {
height: parent.height;
text: root.up-text;
color: Theme.text-secondary;
font-size: Theme.fs-xs;
vertical-alignment: center;
}
Text { // ↓ download (blue)
width: 12px;
height: parent.height;
text: "\u{E5DB}";
font-family: "Material Icons";
color: Theme.accent;
font-size: Theme.fs-xs;
horizontal-alignment: center;
vertical-alignment: center;
}
Text {
height: parent.height;
text: root.down-text;
color: Theme.text-secondary;
font-size: Theme.fs-xs;
vertical-alignment: center;
}
Rectangle { horizontal-stretch: 1; } // spacer pushes selector to the right
if root.show-selector : Rectangle {
width: 64px;
height: 18px;
border-radius: 3px;
background: sel-ta.has-hover ? Theme.bg-hover : Theme.bg-elevated;
HorizontalLayout {
height: parent.height;
padding-left: 5px; padding-right: 4px; spacing: 2px;
Text {
height: parent.height;
text: root.selected;
color: Theme.text-primary;
font-size: Theme.fs-xs;
vertical-alignment: center;
horizontal-stretch: 1;
overflow: elide;
}
Text {
height: parent.height;
text: "\u{E5CF}";
font-family: "Material Icons";
color: Theme.text-muted;
font-size: 8px;
vertical-alignment: center;
}
}
sel-ta := TouchArea {
mouse-cursor: pointer;
clicked => { popup.show(); }
}
popup := PopupWindow {
x: 0; y: parent.height + 2px;
width: 96px;
Rectangle {
background: Theme.bg-panel;
border-radius: 4px;
border-width: 1px;
border-color: Theme.border-strong;
drop-shadow-blur: 10px;
drop-shadow-color: #00000050;
VerticalLayout {
padding: 4px;
spacing: 1px;
for iface in root.ifaces : Rectangle {
height: 22px;
border-radius: 3px;
background: row-ta.has-hover ? Theme.bg-hover : transparent;
row-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.iface-selected(iface); }
}
Text {
x: 6px;
text: iface;
color: Theme.text-primary;
font-size: Theme.fs-xs;
vertical-alignment: center;
height: parent.height;
}
}
}
}
}
}
}
Sparkline {
height: 48px;
values: root.history;
line-color: Theme.accent;
}
}
// --- Sidebar ---------------------------------------------------------------
// Left-hand panel showing local machine health, mimicking FinalShell's
// @tr("Status") column but kept minimal for the v0.1 baseline.
export component Sidebar inherits Rectangle {
in property <bool> app-background-active: false;
in property <float> ui-opacity: 0.72;
in property <string> connection-state: @tr("Not connected");
in property <int> conn-state; // 0 gray / 1 green / 2 yellow
in property <string> resource-title: @tr("Local resources");
in property <float> cpu-percent;
in property <float> mem-percent;
in property <float> swap-percent;
in property <string> cpu-detail;
in property <string> mem-detail;
in property <string> swap-detail;
in property <string> net-top-up;
in property <string> net-top-down;
in property <[float]> net-top-history;
in property <[string]> net-ifaces;
in property <string> net-selected;
in property <bool> net-show-selector;
in property <[DiskInfo]> disks;
in property <[ProcessInfo]> processes;
callback select-net-iface(string);
width: 220px;
background: root.app-background-active ? Theme.bg-panel.with-alpha(root.ui-opacity) : Theme.bg-panel;
border-width: 0;
VerticalLayout {
padding: 10px;
spacing: 10px;
// Status header
VerticalLayout {
spacing: 3px;
Text {
text: @tr("Running status");
color: Theme.text-primary;
font-size: Theme.fs-md;
font-weight: 600;
vertical-alignment: center;
}
Rectangle {
height: 24px;
border-radius: Theme.radius-sm;
background: root.conn-state == 1 ? Theme.success.with-alpha(0.16)
: root.conn-state == 2 ? Theme.warning.with-alpha(0.16)
: Theme.bg-root;
Text {
x: 8px;
width: parent.width - 16px;
height: parent.height;
text: connection-state;
color: root.conn-state == 1 ? Theme.success
: root.conn-state == 2 ? Theme.warning
: Theme.text-secondary;
font-size: Theme.fs-sm;
vertical-alignment: center;
overflow: elide;
}
}
}
Rectangle { height: 1px; background: Theme.border-subtle; }
// System stats — local machine on the welcome tab, the remote server
// when a connected session tab is active.
Text {
text: root.resource-title;
color: Theme.text-secondary;
font-size: Theme.fs-sm;
}
VerticalLayout {
spacing: 6px;
StatRow {
label: "CPU";
percent: root.cpu-percent;
detail: root.cpu-detail;
bar-color: Theme.accent;
}
StatRow {
label: @tr("Memory");
percent: root.mem-percent;
detail: root.mem-detail;
bar-color: Theme.success;
}
StatRow {
label: @tr("Swap");
percent: root.swap-percent;
detail: root.swap-detail;
bar-color: Theme.warning;
}
}
Rectangle { height: 1px; background: Theme.border-subtle; }
ProcessTable {
processes: root.processes;
}
if root.net-top-up != "" || root.net-top-down != "" || root.net-show-selector : Rectangle {
height: 1px;
background: Theme.border-subtle;
}
// Network — follows the active live session tab.
if root.net-top-up != "" || root.net-top-down != "" || root.net-show-selector : VerticalLayout {
vertical-stretch: 0;
spacing: 6px;
NetGraph {
up-text: root.net-top-up;
down-text: root.net-top-down;
history: root.net-top-history;
show-selector: root.net-show-selector;
ifaces: root.net-ifaces;
selected: root.net-selected;
iface-selected(i) => { root.select-net-iface(i); }
}
}
Rectangle { height: 1px; background: Theme.border-subtle; }
// Disk-usage panel (active tab's filesystems; local on the welcome tab).
disk-section := VerticalLayout {
vertical-stretch: 1;
spacing: 3px;
HorizontalLayout {
padding-left: 6px;
padding-right: 6px;
Text {
text: @tr("Path");
color: Theme.text-muted;
font-size: Theme.fs-xs;
horizontal-stretch: 1;
}
Text {
text: @tr("Free/Total");
color: Theme.text-muted;
font-size: Theme.fs-xs;
}
}
disk-flick := Flickable {
vertical-stretch: 1;
viewport-width: self.width;
// Grow only when the list overflows; otherwise match the visible
// height so `alignment: start` keeps rows pinned to the top.
viewport-height: max(self.height, disk-col.preferred-height);
disk-col := VerticalLayout {
alignment: start;
spacing: 1px;
for d[i] in root.disks : Rectangle {
height: 20px;
border-radius: 2px;
clip: true;
// Usage fill: green-ish normally, amber/red when full.
Rectangle {
x: 0;
y: 0;
height: parent.height;
width: parent.width * clamp(d.percent, 0.0, 1.0);
background: d.percent > 0.9 ? Theme.danger.with-alpha(0.32)
: d.percent > 0.75 ? Theme.warning.with-alpha(0.30)
: Theme.accent.with-alpha(0.22);
}
HorizontalLayout {
height: parent.height;
padding-left: 6px;
padding-right: 6px;
spacing: 4px;
Text {
height: parent.height;
text: d.path;
color: Theme.text-secondary;
font-size: Theme.fs-xs;
vertical-alignment: center;
horizontal-stretch: 1;
overflow: elide;
}
Text {
height: parent.height;
text: d.detail;
color: Theme.text-muted;
font-size: Theme.fs-xs;
vertical-alignment: center;
horizontal-alignment: right;
}
}
}
}
}
}
Text {
text: "meatshell v0.2.7";
color: Theme.text-muted;
font-size: Theme.fs-xs;
horizontal-alignment: center;
}
}
}

223
ui/tabs.slint Normal file
View File

@@ -0,0 +1,223 @@
import { Theme } from "theme.slint";
import { IconButton } from "widgets.slint";
export struct TabInfo {
id: string,
title: string,
kind: string, // "welcome" | "terminal"
connected: bool,
}
// --- SingleTab -------------------------------------------------------------
component SingleTab inherits Rectangle {
in property <TabInfo> info;
in property <bool> active;
in property <bool> shown: true;
in property <bool> app-background-active: false;
in property <float> ui-opacity: 0.72;
callback selected();
callback closed();
height: shown ? 30px : 0px;
property <length> title-width: min(128px, max(34px, title-probe.preferred-width));
property <length> tab-width: info.kind == "welcome"
? max(72px, title-width + 34px)
: max(74px, title-width + 62px);
width: shown ? min(176px, tab-width) : 0px;
visible: shown;
horizontal-stretch: 0;
vertical-stretch: 0;
border-radius: Theme.radius-sm;
background: active ? (root.app-background-active ? Theme.bg-panel.with-alpha(root.ui-opacity) : Theme.bg-panel)
: (touch.has-hover ? Theme.bg-hover : transparent);
animate background { duration: 120ms; }
// Declared FIRST so the HorizontalLayout's children (close button) are at
// a higher z-order and receive their own click events.
touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.selected(); }
}
title-probe := Text {
text: info.title;
font-size: Theme.fs-sm;
opacity: 0;
x: -1000px;
y: -1000px;
}
HorizontalLayout {
height: parent.height;
padding-left: 10px;
padding-right: 6px;
spacing: 8px;
alignment: center;
// Status indicator
Rectangle {
width: 8px;
height: parent.height;
Rectangle {
y: (parent.height - self.height) / 2;
width: parent.width;
height: 8px;
border-radius: 4px;
background: info.kind == "welcome" ? Theme.text-muted
: (info.connected ? Theme.success : Theme.warning);
}
}
Text {
text: info.title;
height: parent.height;
color: active ? Theme.text-primary : Theme.text-secondary;
font-size: Theme.fs-sm;
vertical-alignment: center;
overflow: elide;
horizontal-stretch: 1;
}
// Only show close button for non-welcome tabs
if info.kind != "welcome" : Rectangle {
width: 20px;
height: parent.height;
IconButton {
y: (parent.height - self.height) / 2;
glyph: "×";
size: 20px;
clicked => { root.closed(); }
}
}
}
}
// --- TabBar ----------------------------------------------------------------
export component TabBar inherits Rectangle {
in property <[TabInfo]> tabs;
in property <string> active-id;
in-out property <int> visible-start: 0;
in property <bool> app-background-active: false;
in property <float> ui-opacity: 0.72;
property <int> visible-count: 5;
callback tab-selected(string);
callback tab-closed(string);
callback new-tab();
callback previous-tab();
callback next-tab();
height: 36px;
background: root.app-background-active ? Theme.bg-panel-alt.with-alpha(root.ui-opacity) : Theme.bg-panel-alt;
HorizontalLayout {
height: parent.height;
padding-left: 3px;
padding-right: 100px; // reserve the overlaid theme/download/settings buttons
padding-top: 3px;
padding-bottom: 3px;
spacing: 2px;
Rectangle {
width: 30px;
height: 30px;
border-radius: Theme.radius-sm;
background: root.active-id == "welcome"
? Theme.bg-panel
: (folder-ta.has-hover ? Theme.bg-hover : transparent);
folder-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.new-tab(); }
}
Text {
width: parent.width;
height: parent.height;
text: "📂";
color: root.active-id == "welcome" ? Theme.text-primary : Theme.text-secondary;
font-size: Theme.fs-md;
horizontal-alignment: center;
vertical-alignment: center;
}
}
Rectangle {
width: 56px;
height: 30px;
vertical-stretch: 0;
HorizontalLayout {
height: parent.height;
spacing: 0;
Rectangle {
width: 28px;
height: parent.height;
border-radius: Theme.radius-sm;
background: prev-ta.has-hover ? Theme.bg-hover : transparent;
prev-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.previous-tab(); }
}
Text {
width: parent.width;
height: parent.height;
text: "[<]";
color: Theme.text-secondary;
font-size: Theme.fs-xs;
horizontal-alignment: center;
vertical-alignment: center;
}
}
Rectangle {
width: 28px;
height: parent.height;
border-radius: Theme.radius-sm;
background: next-ta.has-hover ? Theme.bg-hover : transparent;
next-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.next-tab(); }
}
Text {
width: parent.width;
height: parent.height;
text: "[>]";
color: Theme.text-secondary;
font-size: Theme.fs-xs;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
}
Rectangle {
horizontal-stretch: 1;
height: 30px;
clip: true;
tabs-flick := Flickable {
width: parent.width;
height: parent.height;
viewport-width: max(self.width, tabs-row.preferred-width);
viewport-height: self.height;
interactive: true;
tabs-row := HorizontalLayout {
height: parent.height;
spacing: 0px;
for tab[i] in root.tabs : SingleTab {
shown: i >= root.visible-start && i < root.visible-start + root.visible-count;
info: tab;
active: tab.id == root.active-id;
app-background-active: root.app-background-active;
ui-opacity: root.ui-opacity;
selected => { root.tab-selected(tab.id); }
closed => { root.tab-closed(tab.id); }
}
}
}
}
}
}

2298
ui/terminal_view.slint Normal file

File diff suppressed because it is too large Load Diff

64
ui/theme.slint Normal file
View File

@@ -0,0 +1,64 @@
// Central design tokens for meatshell.
//
// All colour values are reactive: change `dark` and every widget repaints
// automatically via Slint's property-binding system.
//
// Dark palette — high-contrast charcoal (original meatshell look).
// Light palette — Apple macOS-inspired:
// • Backgrounds use #f5f5f7 (Apple page gray) instead of pure white to
// avoid eye-strain.
// • Primary text is #1d1d1f (Apple near-black) not #000000.
// • Accent is #0071e3 (Apple blue), consistent across both themes.
export global Theme {
// Set by Rust on startup (system detection) and on user toggle.
in-out property <bool> dark: true;
// --- Palette -----------------------------------------------------------
out property <brush> bg-root: dark ? #1b1d23 : #f5f5f7;
out property <brush> bg-panel: dark ? #23262d : #ffffff;
out property <brush> bg-panel-alt: dark ? #2a2d35 : #f2f2f7;
out property <brush> bg-elevated: dark ? #30333c : #e8e8ed;
out property <brush> bg-hover: dark ? #373a44 : #e0e0e6;
out property <brush> bg-active: dark ? #3f4350 : #d8d8de;
out property <brush> border-subtle: dark ? #3a3d46 : #e2e2e8;
out property <brush> border-strong: dark ? #4a4e59 : #c7c7cc;
out property <brush> text-primary: dark ? #e6e8ee : #1d1d1f;
out property <brush> text-secondary: dark ? #b4b9c4 : #6e6e73;
out property <brush> text-muted: dark ? #9196a3 : #aeaeb2;
out property <brush> accent: dark ? #4a90e2 : #0071e3;
out property <brush> accent-hover: dark ? #5aa0f2 : #0077ed;
out property <brush> accent-pressed: dark ? #3a80d2 : #006adb;
out property <brush> success: dark ? #4ec9b0 : #34c759;
out property <brush> warning: dark ? #e2a84a : #ff9f0a;
out property <brush> danger: dark ? #e25c5c : #ff3b30;
// Terminal uses its own background so it stays readable regardless of
// what the shell / remote app outputs via ANSI colour codes.
out property <brush> term-bg: dark ? #0e0f13 : #fafafa;
out property <brush> term-fg: dark ? #d4d4d4 : #2d2d2f;
// --- Typography --------------------------------------------------------
out property <length> fs-xs: 11px;
out property <length> fs-sm: 12px;
out property <length> fs-md: 13px;
out property <length> fs-lg: 15px;
out property <length> fs-xl: 18px;
out property <string> font-ui: "Microsoft YaHei UI";
out property <string> font-mono: "Cascadia Mono";
// --- Geometry ----------------------------------------------------------
out property <length> radius-sm: 4px;
out property <length> radius-md: 6px;
out property <length> radius-lg: 10px;
out property <length> gap-xs: 4px;
out property <length> gap-sm: 8px;
out property <length> gap-md: 12px;
out property <length> gap-lg: 16px;
}

589
ui/welcome.slint Normal file
View File

@@ -0,0 +1,589 @@
import { Theme } from "theme.slint";
import { PrimaryButton } from "widgets.slint";
export struct SessionInfo {
id: string,
name: string,
group: string,
host: string,
port: int,
user: string,
auth: string, // "password" | "key"
kind: string, // "ssh" | "serial" | "telnet"
last-used: string, // ISO timestamp or "never"
}
export struct ConnectionFolderInfo {
name: string,
expanded: bool,
}
// --- SessionRow ------------------------------------------------------------
component SessionRow inherits Rectangle {
in property <SessionInfo> session;
in property <[string]> folder-names;
in property <bool> debug-ids: false;
callback connect();
callback edit();
callback remove();
callback move-folder(string);
height: 34px;
border-radius: Theme.radius-sm;
background: touch.has-hover ? Theme.bg-hover : transparent;
animate background { duration: 120ms; }
// Remember where the right-click landed so the popup can appear there.
property <length> ctx-x;
property <length> ctx-y;
touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.connect(); }
pointer-event(e) => {
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
ctx-x = self.mouse-x;
ctx-y = self.mouse-y;
ctx-menu.show();
}
}
}
// Right-click context menu -----------------------------------------------
ctx-menu := PopupWindow {
x: ctx-x;
y: ctx-y;
width: 142px;
height: 112px + root.folder-names.length * 28px;
Rectangle {
background: Theme.bg-panel;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: Theme.border-strong;
drop-shadow-blur: 10px;
drop-shadow-color: #00000050;
VerticalLayout {
padding: 4px;
spacing: 2px;
// Edit
Rectangle {
height: 28px;
border-radius: Theme.radius-sm;
background: edit-ta.has-hover ? Theme.bg-hover : transparent;
edit-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.edit(); }
}
HorizontalLayout {
height: parent.height;
padding-left: 10px;
spacing: 8px;
alignment: center;
Text { width: 16px; height: parent.height; text: "\u{E3C9}"; font-family: "Material Icons"; color: Theme.text-secondary; font-size: Theme.fs-sm; horizontal-alignment: center; vertical-alignment: center; }
Text { text: @tr("Edit"); color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; }
}
}
Rectangle { height: 1px; background: Theme.border-subtle; }
// Move to category
Rectangle {
height: 28px;
border-radius: Theme.radius-sm;
background: transparent;
HorizontalLayout {
height: parent.height;
padding-left: 10px;
spacing: 8px;
alignment: center;
Text { width: 16px; height: parent.height; text: "\u{E2C8}"; font-family: "Material Icons"; color: Theme.text-secondary; font-size: Theme.fs-sm; horizontal-alignment: center; vertical-alignment: center; }
Text { text: @tr("Move to category"); color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; }
}
}
for folder in root.folder-names : Rectangle {
height: 26px;
border-radius: Theme.radius-sm;
background: folder == root.session.group ? Theme.bg-active : (folder-ta.has-hover ? Theme.bg-hover : transparent);
folder-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.move-folder(folder); }
}
Text {
x: 24px;
width: parent.width - 34px;
height: parent.height;
text: folder;
color: Theme.text-primary;
font-size: Theme.fs-sm;
vertical-alignment: center;
overflow: elide;
}
}
Rectangle { height: 1px; background: Theme.border-subtle; }
// Delete (red tint)
Rectangle {
height: 28px;
border-radius: Theme.radius-sm;
background: del-ta.has-hover ? #cc333320 : transparent;
del-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.remove(); }
}
HorizontalLayout {
height: parent.height;
padding-left: 10px;
spacing: 8px;
alignment: center;
Text { width: 16px; height: parent.height; text: "\u{E872}"; font-family: "Material Icons"; color: #cc3333; font-size: Theme.fs-sm; horizontal-alignment: center; vertical-alignment: center; }
Text { text: @tr("Delete"); color: #cc3333; font-size: Theme.fs-md; vertical-alignment: center; horizontal-stretch: 1; }
}
}
}
}
}
// Row content ------------------------------------------------------------
HorizontalLayout {
width: parent.width;
height: parent.height;
padding-left: 10px;
padding-right: 10px;
spacing: 8px;
Rectangle {
width: 24px;
height: parent.height;
Text {
width: parent.width;
height: parent.height;
text: "\u{2514}";
color: Theme.text-muted;
font-size: Theme.fs-sm;
horizontal-alignment: right;
vertical-alignment: center;
}
}
Rectangle {
width: 18px;
height: parent.height;
Rectangle {
y: (parent.height - self.height) / 2;
width: parent.width;
height: 18px;
border-radius: 3px;
background: Theme.bg-elevated;
Text {
width: parent.width;
height: parent.height;
text: session.kind == "serial" ? "\u{E1B1}"
: session.kind == "telnet" ? "\u{E8AC}"
: "\u{E875}";
font-family: "Material Icons";
color: Theme.text-secondary;
font-size: Theme.fs-sm;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
Text {
height: parent.height;
text: session.name;
color: Theme.text-primary;
font-size: Theme.fs-md;
vertical-alignment: center;
width: 170px;
overflow: elide;
}
Rectangle {
width: 52px;
height: parent.height;
Rectangle {
y: (parent.height - self.height) / 2;
width: parent.width;
height: 20px;
border-radius: Theme.radius-sm;
background: Theme.bg-elevated;
Text {
width: parent.width;
height: parent.height;
text: session.kind == "serial" ? "SERIAL"
: session.kind == "telnet" ? "TELNET"
: "SSH";
color: Theme.text-secondary;
font-size: Theme.fs-xs;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
Text {
height: parent.height;
text: session.kind == "serial"
? session.host
: session.host + ":" + session.port;
color: Theme.text-secondary;
font-size: Theme.fs-sm;
vertical-alignment: center;
width: 220px;
overflow: elide;
}
Text {
height: parent.height;
text: session.user;
color: Theme.text-secondary;
font-size: Theme.fs-sm;
vertical-alignment: center;
horizontal-stretch: 1;
overflow: elide;
}
if root.debug-ids : Text {
height: parent.height;
text: "[session-row]";
color: Theme.warning;
font-size: Theme.fs-xs;
vertical-alignment: center;
}
}
}
component FolderSessionSlot inherits Rectangle {
in property <string> folder-name;
in property <bool> folder-expanded: true;
in property <SessionInfo> session;
in property <[string]> folder-names;
in property <bool> debug-ids: false;
callback connect-session(string);
callback edit-session(string);
callback remove-session(string);
callback move-session-folder(string, string);
height: root.folder-expanded && session.group == root.folder-name ? 34px : 0px;
visible: root.folder-expanded && session.group == root.folder-name;
if root.folder-expanded && session.group == root.folder-name : SessionRow {
width: parent.width;
height: parent.height;
session: root.session;
folder-names: root.folder-names;
debug-ids: root.debug-ids;
connect => { root.connect-session(root.session.id); }
edit => { root.edit-session(root.session.id); }
remove => { root.remove-session(root.session.id); }
move-folder(folder) => { root.move-session-folder(root.session.id, folder); }
}
}
component FolderSection inherits VerticalLayout {
in property <string> folder-name;
in property <bool> expanded: true;
in property <[SessionInfo]> sessions;
in property <[string]> folder-names;
in property <bool> debug-ids: false;
callback connect-session(string);
callback edit-session(string);
callback remove-session(string);
callback rename-folder(string);
callback delete-folder(string);
callback new-folder();
callback toggle-folder(string);
callback move-session-folder(string, string);
spacing: 2px;
Rectangle {
width: parent.width;
height: 28px;
border-radius: Theme.radius-sm;
background: header-ta.has-hover ? Theme.bg-hover : transparent;
property <length> ctx-x;
property <length> ctx-y;
header-ta := TouchArea {
mouse-cursor: pointer;
pointer-event(e) => {
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
ctx-x = self.mouse-x;
ctx-y = self.mouse-y;
folder-menu.show();
} else if (e.kind == PointerEventKind.down && e.button == PointerEventButton.left) {
root.toggle-folder(root.folder-name);
}
}
}
folder-menu := PopupWindow {
x: ctx-x;
y: ctx-y;
width: 132px;
height: 108px;
Rectangle {
background: Theme.bg-panel;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: Theme.border-strong;
drop-shadow-blur: 10px;
drop-shadow-color: #00000050;
VerticalLayout {
padding: 4px;
spacing: 2px;
Rectangle {
height: 28px;
border-radius: Theme.radius-sm;
background: new-folder-ta.has-hover ? Theme.bg-hover : transparent;
new-folder-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.new-folder(); }
}
Text { x: 10px; width: parent.width - 20px; height: parent.height; text: @tr("New category"); color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; }
}
Rectangle {
height: 28px;
border-radius: Theme.radius-sm;
background: rename-folder-ta.has-hover ? Theme.bg-hover : transparent;
rename-folder-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.rename-folder(root.folder-name); }
}
Text { x: 10px; width: parent.width - 20px; height: parent.height; text: @tr("Rename"); color: Theme.text-primary; font-size: Theme.fs-md; vertical-alignment: center; }
}
Rectangle {
height: 28px;
border-radius: Theme.radius-sm;
background: delete-folder-ta.has-hover ? #cc333320 : transparent;
delete-folder-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.delete-folder(root.folder-name); }
}
Text { x: 10px; width: parent.width - 20px; height: parent.height; text: @tr("Delete"); color: #cc3333; font-size: Theme.fs-md; vertical-alignment: center; }
}
}
}
}
HorizontalLayout {
width: parent.width;
height: parent.height;
spacing: 8px;
padding-left: 10px;
padding-right: 10px;
Rectangle {
width: 24px;
height: parent.height;
Text {
width: parent.width;
height: parent.height;
text: root.expanded ? "\u{E5CF}" : "\u{E5CC}";
font-family: "Material Icons";
color: Theme.text-secondary;
font-size: 16px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
Text {
height: parent.height;
text: root.folder-name;
color: Theme.text-secondary;
font-size: Theme.fs-sm;
font-weight: 600;
vertical-alignment: center;
horizontal-stretch: 1;
overflow: elide;
}
if root.debug-ids : Text {
height: parent.height;
text: "[folder-section]";
color: Theme.warning;
font-size: Theme.fs-xs;
vertical-alignment: center;
}
}
}
for session in root.sessions : FolderSessionSlot {
width: parent.width;
folder-name: root.folder-name;
folder-expanded: root.expanded;
session: session;
folder-names: root.folder-names;
debug-ids: root.debug-ids;
connect-session(id) => { root.connect-session(id); }
edit-session(id) => { root.edit-session(id); }
remove-session(id) => { root.remove-session(id); }
move-session-folder(id, folder) => { root.move-session-folder(id, folder); }
}
}
// --- Welcome ---------------------------------------------------------------
export component Welcome inherits Rectangle {
in property <bool> app-background-active: false;
in property <float> ui-opacity: 0.72;
in property <[SessionInfo]> sessions;
in property <[ConnectionFolderInfo]> folders;
in property <[string]> all-folder-names;
in property <string> import-hint; // result text after an import
in property <bool> debug-ids: false;
callback new-session();
callback new-folder();
callback rename-folder(string);
callback delete-folder(string);
callback toggle-folder(string);
callback move-session-folder(string, string);
callback import-ssh-config();
callback connect-session(string);
callback edit-session(string);
callback remove-session(string);
background: root.app-background-active ? Theme.bg-root.with-alpha(root.ui-opacity) : Theme.bg-root;
clip: true;
Flickable {
width: parent.width;
height: parent.height;
viewport-width: self.width;
viewport-height: max(self.height, content.preferred-height);
content := VerticalLayout {
width: root.width;
padding: 24px;
spacing: 20px;
VerticalLayout {
spacing: 4px;
Text {
text: "meatshell";
color: Theme.text-primary;
font-size: 28px;
font-weight: 700;
}
Text {
text: @tr("meatshell — a lightweight Rust + Slint SSH client by 'lil meatball'");
color: Theme.text-secondary;
font-size: Theme.fs-md;
wrap: word-wrap;
}
}
HorizontalLayout {
spacing: 8px;
Rectangle { horizontal-stretch: 1; }
PrimaryButton {
text: @tr("New SSH");
clicked => { root.new-session(); }
}
}
// Connection management
Rectangle {
background: Theme.bg-panel;
border-radius: Theme.radius-md;
border-width: 1px;
border-color: Theme.border-subtle;
height: max(240px, min(560px, root.height - 190px));
VerticalLayout {
padding: 16px;
spacing: 12px;
HorizontalLayout {
spacing: 8px;
Text {
text: @tr("Connection manager");
color: Theme.text-primary;
font-size: Theme.fs-lg;
font-weight: 600;
vertical-alignment: center;
horizontal-stretch: 1;
}
// Result of the last "Import ~/.ssh/config" (triggered from
// the settings menu), e.g. "imported 3".
Text {
text: root.import-hint;
color: Theme.text-muted;
font-size: Theme.fs-sm;
vertical-alignment: center;
overflow: elide;
}
}
Rectangle {
vertical-stretch: 1;
clip: true;
background: transparent;
if sessions.length == 0 : Rectangle {
width: parent.width;
height: parent.height;
background: Theme.bg-root;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: Theme.border-subtle;
VerticalLayout {
alignment: center;
spacing: 6px;
Text {
text: @tr("No sessions yet");
color: Theme.text-secondary;
font-size: Theme.fs-md;
horizontal-alignment: center;
}
Text {
text: @tr("Use the plus button above to add SSH, Serial, or Telnet");
color: Theme.text-muted;
font-size: Theme.fs-sm;
horizontal-alignment: center;
wrap: word-wrap;
}
}
}
if root.folders.length > 0 : Flickable {
width: parent.width;
height: parent.height;
viewport-width: self.width;
viewport-height: max(self.height, folder-list.preferred-height);
folder-list := VerticalLayout {
width: parent.width;
spacing: 4px;
for folder in root.folders : FolderSection {
width: parent.width;
folder-name: folder.name;
expanded: folder.expanded;
sessions: root.sessions;
folder-names: root.all-folder-names;
debug-ids: root.debug-ids;
connect-session(id) => { root.connect-session(id); }
edit-session(id) => { root.edit-session(id); }
remove-session(id) => { root.remove-session(id); }
rename-folder(name) => { root.rename-folder(name); }
delete-folder(name) => { root.delete-folder(name); }
new-folder() => { root.new-folder(); }
toggle-folder(name) => { root.toggle-folder(name); }
move-session-folder(id, folder) => { root.move-session-folder(id, folder); }
}
}
}
}
}
}
// Filler
Rectangle { vertical-stretch: 1; }
}
}
}

174
ui/widgets.slint Normal file
View File

@@ -0,0 +1,174 @@
import { Theme } from "theme.slint";
// --- IconButton ------------------------------------------------------------
// A small square action button that accepts a single-character glyph (or short text).
// Keeps visual language consistent across the app.
export component IconButton inherits Rectangle {
in property <string> glyph;
in property <string> tooltip;
in property <length> size: 28px;
in property <brush> fg: Theme.text-secondary;
in property <brush> fg-hover: Theme.text-primary;
callback clicked();
width: size;
height: size;
border-radius: Theme.radius-sm;
background: touch.has-hover ? Theme.bg-hover : transparent;
animate background { duration: 120ms; }
Text {
width: parent.width;
height: parent.height;
text: glyph;
color: touch.has-hover ? fg-hover : fg;
font-size: Theme.fs-md;
horizontal-alignment: center;
vertical-alignment: center;
}
touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
}
// --- PrimaryButton ---------------------------------------------------------
export component PrimaryButton inherits Rectangle {
in property <string> text;
in property <bool> disabled: false;
callback clicked();
height: 30px;
min-width: 80px;
border-radius: Theme.radius-sm;
background: disabled
? Theme.bg-elevated
: (touch.pressed ? Theme.accent-pressed
: (touch.has-hover ? Theme.accent-hover : Theme.accent));
animate background { duration: 120ms; }
HorizontalLayout {
height: parent.height;
padding-left: 14px;
padding-right: 14px;
alignment: center;
Text {
height: parent.height;
text: root.text;
color: disabled ? Theme.text-muted : white;
font-size: Theme.fs-md;
font-weight: 600;
horizontal-alignment: center;
vertical-alignment: center;
}
}
touch := TouchArea {
enabled: !disabled;
mouse-cursor: disabled ? default : pointer;
clicked => { root.clicked(); }
}
}
// --- GhostButton -----------------------------------------------------------
export component GhostButton inherits Rectangle {
in property <string> text;
callback clicked();
height: 30px;
min-width: 80px;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: touch.has-hover ? Theme.border-strong : Theme.border-subtle;
background: touch.pressed ? Theme.bg-active
: (touch.has-hover ? Theme.bg-hover : Theme.bg-panel);
animate background, border-color { duration: 120ms; }
HorizontalLayout {
height: parent.height;
padding-left: 14px;
padding-right: 14px;
alignment: center;
Text {
height: parent.height;
text: root.text;
color: Theme.text-primary;
font-size: Theme.fs-md;
horizontal-alignment: center;
vertical-alignment: center;
}
}
touch := TouchArea {
mouse-cursor: pointer;
clicked => { root.clicked(); }
}
}
// --- LabeledInput ----------------------------------------------------------
export component LabeledInput inherits VerticalLayout {
in property <string> label;
in property <string> placeholder;
in-out property <string> value;
in property <bool> password: false;
spacing: 4px;
Text {
text: label;
color: Theme.text-secondary;
font-size: Theme.fs-sm;
}
Rectangle {
height: 32px;
border-radius: Theme.radius-sm;
border-width: 1px;
border-color: input.has-focus ? Theme.accent : Theme.border-subtle;
background: Theme.bg-root;
input := TextInput {
x: 10px;
y: 0px;
width: parent.width - 20px;
height: parent.height;
text <=> root.value;
color: Theme.text-primary;
font-size: Theme.fs-md;
vertical-alignment: center;
single-line: true;
input-type: root.password ? InputType.password : InputType.text;
}
// Lightweight placeholder — shown when value is empty.
if value == "" : Text {
x: 10px;
y: 0px;
height: parent.height;
text: placeholder;
color: Theme.text-muted;
font-size: Theme.fs-md;
vertical-alignment: center;
}
}
}
// --- Sparkline -------------------------------------------------------------
// Compact horizontal bar chart used in the stats sidebar.
export component Sparkline inherits Rectangle {
in property <[float]> values;
in property <brush> line-color: Theme.accent;
background: Theme.bg-root;
border-radius: Theme.radius-sm;
clip: true;
for v[i] in values : Rectangle {
x: i * (parent.width / max(1, values.length));
y: parent.height - (parent.height * clamp(v, 0.0, 1.0));
width: (parent.width / max(1, values.length)) - 1px;
height: parent.height * clamp(v, 0.0, 1.0);
background: line-color.with-alpha(0.85);
border-radius: 1px;
}
}