20260619
This commit is contained in:
500
.gitea/workflows/release.yml
Normal file
500
.gitea/workflows/release.yml
Normal 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
10
.gitignore
vendored
Normal 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
192
CHANGELOG.md
Normal 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
66
CONTRIBUTING.md
Normal 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
77
Cargo.toml
Normal 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
111
Dockerfile.Miu_v1
Normal 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
147
README.en.md
Normal 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
142
README.md
Normal 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.35(Ubuntu 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
44
assets/Info.plist
Normal 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
BIN
assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
assets/icon@512.png
Normal file
BIN
assets/icon@512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
81
assets/install-linux.sh
Normal file
81
assets/install-linux.sh
Normal 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
169
assets/make_icon.py
Normal 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
13
assets/meatshell.desktop
Normal 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
BIN
assets/meatshell.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
30
build.rs
Normal file
30
build.rs
Normal 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
docs/screenshots/01-welcome-en.png
Normal file
BIN
docs/screenshots/01-welcome-en.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 73 KiB |
BIN
docs/screenshots/01-welcome.png
Normal file
BIN
docs/screenshots/01-welcome.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
BIN
docs/screenshots/02-terminal-btop.png
Normal file
BIN
docs/screenshots/02-terminal-btop.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 211 KiB |
120
docs/ui-layout.md
Normal file
120
docs/ui-layout.md
Normal 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.
|
||||||
355
lang/en/LC_MESSAGES/meatshell.po
Normal file
355
lang/en/LC_MESSAGES/meatshell.po
Normal 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"
|
||||||
355
lang/zh/LC_MESSAGES/meatshell.po
Normal file
355
lang/zh/LC_MESSAGES/meatshell.po
Normal 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
5125
src/app.rs
Normal file
File diff suppressed because it is too large
Load Diff
1267
src/config.rs
Normal file
1267
src/config.rs
Normal file
File diff suppressed because it is too large
Load Diff
155
src/debug_log.rs
Normal file
155
src/debug_log.rs
Normal 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
64
src/i18n.rs
Normal 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
331
src/keychain.rs
Normal 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
286
src/known_hosts.rs
Normal 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
46
src/main.rs
Normal 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
176
src/proxy.rs
Normal 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
211
src/serial.rs
Normal 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
1172
src/sftp.rs
Normal file
File diff suppressed because it is too large
Load Diff
962
src/ssh.rs
Normal file
962
src/ssh.rs
Normal 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
244
src/ssh_config.rs
Normal 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
53
src/system.rs
Normal 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
272
src/telnet.rs
Normal 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
1608
ui/app.slint
Normal file
File diff suppressed because it is too large
Load Diff
123
ui/confirm_dialog.slint
Normal file
123
ui/confirm_dialog.slint
Normal 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(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
ui/fonts/CascadiaMono-Bold.ttf
Normal file
BIN
ui/fonts/CascadiaMono-Bold.ttf
Normal file
Binary file not shown.
BIN
ui/fonts/CascadiaMono-Regular.ttf
Normal file
BIN
ui/fonts/CascadiaMono-Regular.ttf
Normal file
Binary file not shown.
BIN
ui/fonts/MaterialIcons-Regular.ttf
Normal file
BIN
ui/fonts/MaterialIcons-Regular.ttf
Normal file
Binary file not shown.
454
ui/session_dialog.slint
Normal file
454
ui/session_dialog.slint
Normal 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
591
ui/sftp_panel.slint
Normal 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
499
ui/sidebar.slint
Normal 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
223
ui/tabs.slint
Normal 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
2298
ui/terminal_view.slint
Normal file
File diff suppressed because it is too large
Load Diff
64
ui/theme.slint
Normal file
64
ui/theme.slint
Normal 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
589
ui/welcome.slint
Normal 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
174
ui/widgets.slint
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user