This commit is contained in:
@@ -11,18 +11,13 @@ on:
|
|||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
workflow_dispatch:
|
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:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-windows-gnu:
|
build-windows-gnu:
|
||||||
name: windows-x86_64-gnu
|
name: windows-x86_64-gnu
|
||||||
runs-on: Ubuntu_docker_act
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
env:
|
env:
|
||||||
TARGET: x86_64-pc-windows-gnu
|
TARGET: x86_64-pc-windows-gnu
|
||||||
@@ -36,72 +31,24 @@ jobs:
|
|||||||
PKG_CONFIG_ALLOW_CROSS: "1"
|
PKG_CONFIG_ALLOW_CROSS: "1"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: 显示代理配置
|
- name: Install system dependencies
|
||||||
shell: bash
|
|
||||||
run: |
|
run: |
|
||||||
echo "=== Proxy Configuration ==="
|
sudo dpkg --add-architecture i386
|
||||||
echo "HTTP_PROXY: ${HTTP_PROXY}"
|
sudo apt-get update
|
||||||
echo "HTTPS_PROXY: ${HTTPS_PROXY}"
|
sudo apt-get install -y \
|
||||||
echo "NO_PROXY: ${NO_PROXY}"
|
gcc-mingw-w64-x86-64 \
|
||||||
echo "=========================="
|
binutils-mingw-w64-x86-64 \
|
||||||
|
wine64 wine32 wine \
|
||||||
- name: 测试代理连通性
|
xvfb \
|
||||||
shell: bash
|
zip
|
||||||
run: |
|
sudo apt-get clean
|
||||||
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
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
clean: false
|
clean: false
|
||||||
|
|
||||||
- name: Cache Cargo registry and target
|
- name: Cache Cargo registry and target
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/registry
|
~/.cargo/registry
|
||||||
@@ -112,51 +59,17 @@ jobs:
|
|||||||
${{ runner.os }}-${{ env.TARGET }}-cargo-
|
${{ runner.os }}-${{ env.TARGET }}-cargo-
|
||||||
${{ runner.os }}-cargo-
|
${{ runner.os }}-cargo-
|
||||||
|
|
||||||
- name: Configure local Cargo cache
|
- name: Install Rust toolchain
|
||||||
shell: bash
|
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
rustup target add "$TARGET"
|
||||||
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
|
- name: Show toolchain
|
||||||
shell: bash
|
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
|
||||||
rustc --version
|
rustc --version
|
||||||
cargo --version
|
cargo --version
|
||||||
x86_64-w64-mingw32-gcc --version | head -n 1
|
x86_64-w64-mingw32-gcc --version | head -n 1
|
||||||
x86_64-w64-mingw32-windres --version | head -n 1
|
|
||||||
|
|
||||||
- name: Ensure Rust target
|
- name: Fetch dependencies
|
||||||
shell: bash
|
|
||||||
run: rustup target add "$TARGET"
|
|
||||||
|
|
||||||
- name: Update crates.io index with retry
|
|
||||||
shell: bash
|
|
||||||
run: |
|
run: |
|
||||||
for i in 1 2 3 4 5; do
|
for i in 1 2 3 4 5; do
|
||||||
echo "Attempt $i to fetch dependencies..."
|
echo "Attempt $i to fetch dependencies..."
|
||||||
@@ -172,45 +85,11 @@ jobs:
|
|||||||
sleep 10
|
sleep 10
|
||||||
done
|
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
|
- name: Build Windows GNU release
|
||||||
shell: bash
|
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
cargo build --release --target "$TARGET"
|
||||||
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
|
- name: Package Windows zip
|
||||||
shell: bash
|
|
||||||
env:
|
env:
|
||||||
EVENT_NAME: ${{ gitea.event_name }}
|
EVENT_NAME: ${{ gitea.event_name }}
|
||||||
REF: ${{ gitea.ref }}
|
REF: ${{ gitea.ref }}
|
||||||
@@ -218,9 +97,9 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
BUILD_DIR="${CARGO_TARGET_DIR:-target}"
|
BUILD_DIR="${CARGO_TARGET_DIR:-target}"
|
||||||
event_name="${EVENT_NAME:-${GITHUB_EVENT_NAME:-}}"
|
event_name="${EVENT_NAME:-}"
|
||||||
ref="${REF:-${GITHUB_REF:-}}"
|
ref="${REF:-}"
|
||||||
ref_name="${REF_NAME:-${GITHUB_REF_NAME:-dev}}"
|
ref_name="${REF_NAME:-dev}"
|
||||||
short_sha="$(git rev-parse --short=12 HEAD)"
|
short_sha="$(git rev-parse --short=12 HEAD)"
|
||||||
if [ "$event_name" = "push" ] && [ "$ref" = "refs/tags/${ref_name}" ]; then
|
if [ "$event_name" = "push" ] && [ "$ref" = "refs/tags/${ref_name}" ]; then
|
||||||
VERSION="${ref_name}-${short_sha}"
|
VERSION="${ref_name}-${short_sha}"
|
||||||
@@ -238,263 +117,18 @@ jobs:
|
|||||||
mkdir -p "$STAGE"
|
mkdir -p "$STAGE"
|
||||||
cp "${BUILD_DIR}/${TARGET}/release/${BIN}" "$STAGE/"
|
cp "${BUILD_DIR}/${TARGET}/release/${BIN}" "$STAGE/"
|
||||||
cp README.md README.en.md CHANGELOG.md "$STAGE/" 2>/dev/null || true
|
cp README.md README.en.md CHANGELOG.md "$STAGE/" 2>/dev/null || true
|
||||||
if command -v 7z >/dev/null 2>&1; then
|
zip -r "${STAGE}.zip" "$STAGE"
|
||||||
7z a "${STAGE}.zip" "$STAGE" >/dev/null
|
|
||||||
else
|
|
||||||
zip -r "${STAGE}.zip" "$STAGE"
|
|
||||||
fi
|
|
||||||
echo "ASSET=${STAGE}.zip" >> "$GITHUB_ENV"
|
echo "ASSET=${STAGE}.zip" >> "$GITHUB_ENV"
|
||||||
echo "RELEASE_TAG=${RELEASE_TAG}" >> "$GITHUB_ENV"
|
echo "RELEASE_TAG=${RELEASE_TAG}" >> "$GITHUB_ENV"
|
||||||
echo "RELEASE_NAME=${RELEASE_NAME}" >> "$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
|
- name: Publish Gitea release
|
||||||
shell: bash
|
if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main'
|
||||||
env:
|
uses: https://gitea.com/actions/release-action@main
|
||||||
API_TOKEN: ${{ env.RELEASE_API_KEY }}
|
with:
|
||||||
RELEASE_TAG_ENV: ${{ env.RELEASE_TAG }}
|
files: ${{ env.ASSET }}
|
||||||
RELEASE_NAME_ENV: ${{ env.RELEASE_NAME }}
|
tag_name: ${{ env.RELEASE_TAG }}
|
||||||
EVENT_NAME: ${{ gitea.event_name }}
|
name: ${{ env.RELEASE_NAME }}
|
||||||
REF: ${{ gitea.ref }}
|
api_key: ${{ secrets.GITHUB_TOKEN }}
|
||||||
REF_NAME: ${{ gitea.ref_name }}
|
draft: false
|
||||||
RUN_ID: ${{ gitea.run_id }}
|
prerelease: false
|
||||||
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"
|
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ dark-light = "1"
|
|||||||
# Runtime background image decoding/blur. Used for png/jpg/jpeg/webp user
|
# Runtime background image decoding/blur. Used for png/jpg/jpeg/webp user
|
||||||
# backgrounds before handing pixels to Slint.
|
# backgrounds before handing pixels to Slint.
|
||||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
|
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||||
|
# HTTP client for WebDAV sync.
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||||
|
|
||||||
# Decode the embedded PNG icon to set the window icon at runtime (Linux only).
|
# 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 Windows the icon comes from the .ico embedded by winresource at link time;
|
||||||
|
|||||||
@@ -353,3 +353,24 @@ msgstr "Off"
|
|||||||
|
|
||||||
msgid "Shows stable ids on UI parts for bug reports"
|
msgid "Shows stable ids on UI parts for bug reports"
|
||||||
msgstr "Shows stable ids on UI parts for bug reports"
|
msgstr "Shows stable ids on UI parts for bug reports"
|
||||||
|
|
||||||
|
msgid "WebDAV sync"
|
||||||
|
msgstr "WebDAV sync"
|
||||||
|
|
||||||
|
msgid "WebDAV URL"
|
||||||
|
msgstr "WebDAV URL"
|
||||||
|
|
||||||
|
msgid "WebDAV username"
|
||||||
|
msgstr "WebDAV username"
|
||||||
|
|
||||||
|
msgid "Password"
|
||||||
|
msgstr "Password"
|
||||||
|
|
||||||
|
msgid "Test"
|
||||||
|
msgstr "Test"
|
||||||
|
|
||||||
|
msgid "Upload"
|
||||||
|
msgstr "Upload"
|
||||||
|
|
||||||
|
msgid "Download"
|
||||||
|
msgstr "Download"
|
||||||
|
|||||||
@@ -353,3 +353,24 @@ msgstr "关闭"
|
|||||||
|
|
||||||
msgid "Shows stable ids on UI parts for bug reports"
|
msgid "Shows stable ids on UI parts for bug reports"
|
||||||
msgstr "在界面部件上显示固定 ID,便于反馈问题"
|
msgstr "在界面部件上显示固定 ID,便于反馈问题"
|
||||||
|
|
||||||
|
msgid "WebDAV sync"
|
||||||
|
msgstr "WebDAV 同步"
|
||||||
|
|
||||||
|
msgid "WebDAV URL"
|
||||||
|
msgstr "WebDAV 地址"
|
||||||
|
|
||||||
|
msgid "WebDAV username"
|
||||||
|
msgstr "WebDAV 用户名"
|
||||||
|
|
||||||
|
msgid "Password"
|
||||||
|
msgstr "密码"
|
||||||
|
|
||||||
|
msgid "Test"
|
||||||
|
msgstr "测试"
|
||||||
|
|
||||||
|
msgid "Upload"
|
||||||
|
msgstr "上传"
|
||||||
|
|
||||||
|
msgid "Download"
|
||||||
|
msgstr "下载"
|
||||||
|
|||||||
214
src/app.rs
214
src/app.rs
@@ -444,6 +444,14 @@ pub fn run() -> Result<()> {
|
|||||||
.to_string()
|
.to_string()
|
||||||
.into(),
|
.into(),
|
||||||
);
|
);
|
||||||
|
// Load WebDAV settings into the UI.
|
||||||
|
{
|
||||||
|
let s = store.borrow();
|
||||||
|
window.set_webdav_enabled(s.webdav_enabled());
|
||||||
|
window.set_webdav_url_text(s.webdav_url().to_string().into());
|
||||||
|
window.set_webdav_username_text(s.webdav_username().to_string().into());
|
||||||
|
window.set_webdav_password_text(s.webdav_password().as_str().to_string().into());
|
||||||
|
}
|
||||||
window.on_debug_click(move |parent, button| {
|
window.on_debug_click(move |parent, button| {
|
||||||
let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f");
|
let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f");
|
||||||
tracing::info!("[{now}]点击[{button}/{parent}]");
|
tracing::info!("[{now}]点击[{button}/{parent}]");
|
||||||
@@ -648,6 +656,212 @@ pub fn run() -> Result<()> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// --- WebDAV sync callbacks -------------------------------------------
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let store = store.clone();
|
||||||
|
window.on_webdav_save_settings(move |enabled, url, username, password| {
|
||||||
|
{
|
||||||
|
let mut s = store.borrow_mut();
|
||||||
|
s.set_webdav_enabled(enabled);
|
||||||
|
s.set_webdav_url(url.to_string());
|
||||||
|
s.set_webdav_username(username.to_string());
|
||||||
|
s.set_webdav_password(crate::config::Secret::new(password.to_string()));
|
||||||
|
if let Err(err) = s.save() {
|
||||||
|
tracing::warn!("failed to save WebDAV settings: {err:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
w.set_webdav_enabled(enabled);
|
||||||
|
w.set_webdav_url_text(url);
|
||||||
|
w.set_webdav_username_text(username);
|
||||||
|
w.set_webdav_password_text(password);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let store = store.clone();
|
||||||
|
let rt = runtime.clone();
|
||||||
|
window.on_webdav_test_connection(move || {
|
||||||
|
let url: SharedString = store.borrow().webdav_url().to_string().into();
|
||||||
|
let username: SharedString = store.borrow().webdav_username().to_string().into();
|
||||||
|
let password: SharedString = store.borrow().webdav_password().as_str().to_string().into();
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
w.set_webdav_status_text(t("测试连接中...", "Testing...").into());
|
||||||
|
}
|
||||||
|
let weak2 = weak.clone();
|
||||||
|
rt.spawn(async move {
|
||||||
|
let result = crate::webdav::test_connection(
|
||||||
|
url.as_str(),
|
||||||
|
username.as_str(),
|
||||||
|
password.as_str(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let test_result = match result {
|
||||||
|
Ok(()) => Ok(t("连接成功", "Connection OK").to_string()),
|
||||||
|
Err(err) => Err(format!("{}: {err:#}", t("连接失败", "Connection failed"))),
|
||||||
|
};
|
||||||
|
let _ = slint::invoke_from_event_loop(move || {
|
||||||
|
if let Some(w) = weak2.upgrade() {
|
||||||
|
let msg = match test_result {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => e,
|
||||||
|
};
|
||||||
|
w.set_webdav_status_text(msg.into());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let store = store.clone();
|
||||||
|
let rt = runtime.clone();
|
||||||
|
window.on_webdav_push(move || {
|
||||||
|
let url: SharedString = store.borrow().webdav_url().to_string().into();
|
||||||
|
let username: SharedString = store.borrow().webdav_username().to_string().into();
|
||||||
|
let password: SharedString = store.borrow().webdav_password().as_str().to_string().into();
|
||||||
|
{
|
||||||
|
let s = store.borrow();
|
||||||
|
if let Err(err) = s.save() {
|
||||||
|
tracing::warn!("failed to save config before WebDAV push: {err:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let config_path = {
|
||||||
|
let s = store.borrow();
|
||||||
|
s.config_dir().join("sessions.json")
|
||||||
|
};
|
||||||
|
let config_json = match std::fs::read_to_string(&config_path) {
|
||||||
|
Ok(json) => json,
|
||||||
|
Err(err) => {
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
w.set_webdav_status_text(
|
||||||
|
format!("{}: {err:#}", t("读取配置失败", "Failed to read config"))
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
w.set_webdav_status_text(t("正在上传...", "Uploading...").into());
|
||||||
|
}
|
||||||
|
let weak2 = weak.clone();
|
||||||
|
rt.spawn(async move {
|
||||||
|
let result =
|
||||||
|
crate::webdav::push(url.as_str(), username.as_str(), password.as_str(), &config_json)
|
||||||
|
.await;
|
||||||
|
let push_result = match result {
|
||||||
|
Ok(()) => Ok(t("上传成功", "Upload OK").to_string()),
|
||||||
|
Err(err) => Err(format!("{}: {err:#}", t("上传失败", "Upload failed"))),
|
||||||
|
};
|
||||||
|
let _ = slint::invoke_from_event_loop(move || {
|
||||||
|
if let Some(w) = weak2.upgrade() {
|
||||||
|
let msg = match push_result {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => e,
|
||||||
|
};
|
||||||
|
w.set_webdav_status_text(msg.into());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let store = store.clone();
|
||||||
|
let rt = runtime.clone();
|
||||||
|
let sessions_model = sessions_model.clone();
|
||||||
|
let folders_names_model = connection_folder_names_model.clone();
|
||||||
|
let pull_result: Arc<Mutex<Option<std::result::Result<String, String>>>> =
|
||||||
|
Arc::new(Mutex::new(None));
|
||||||
|
{
|
||||||
|
let pull_result = pull_result.clone();
|
||||||
|
let weak = weak.clone();
|
||||||
|
let store = store.clone();
|
||||||
|
let sessions_model = sessions_model.clone();
|
||||||
|
let folders_model = connection_folders_model.clone();
|
||||||
|
let folders_names_model = folders_names_model.clone();
|
||||||
|
let timer = slint::Timer::default();
|
||||||
|
timer.start(
|
||||||
|
slint::TimerMode::Repeated,
|
||||||
|
std::time::Duration::from_millis(200),
|
||||||
|
move || {
|
||||||
|
let mut guard = pull_result.lock().unwrap();
|
||||||
|
if let Some(result) = guard.take() {
|
||||||
|
drop(guard);
|
||||||
|
match result {
|
||||||
|
Ok(json) => {
|
||||||
|
match serde_json::from_str::<crate::config::ConfigFile>(&json) {
|
||||||
|
Ok(new_cfg) => {
|
||||||
|
{
|
||||||
|
let mut s = store.borrow_mut();
|
||||||
|
s.replace_config(new_cfg);
|
||||||
|
if let Err(err) = s.save() {
|
||||||
|
tracing::warn!(
|
||||||
|
"failed to save downloaded config: {err:#}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let s = store.borrow();
|
||||||
|
sync_sessions_to_model(&s, &sessions_model);
|
||||||
|
sync_connection_folders_to_model(
|
||||||
|
&s,
|
||||||
|
&folders_model,
|
||||||
|
&folders_names_model,
|
||||||
|
);
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
w.set_webdav_status_text(
|
||||||
|
t("下载成功", "Download OK").to_string().into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
w.set_webdav_status_text(
|
||||||
|
format!(
|
||||||
|
"{}: {err:#}",
|
||||||
|
t("解析配置失败", "Failed to parse config")
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
w.set_webdav_status_text(err.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Keep the timer alive for the app's lifetime by leaking it.
|
||||||
|
// A more refined approach would store it in a global.
|
||||||
|
std::mem::forget(timer);
|
||||||
|
}
|
||||||
|
window.on_webdav_pull(move || {
|
||||||
|
let url: SharedString = store.borrow().webdav_url().to_string().into();
|
||||||
|
let username: SharedString = store.borrow().webdav_username().to_string().into();
|
||||||
|
let password: SharedString = store.borrow().webdav_password().as_str().to_string().into();
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
w.set_webdav_status_text(t("正在下载...", "Downloading...").into());
|
||||||
|
}
|
||||||
|
let pull_result = pull_result.clone();
|
||||||
|
rt.spawn(async move {
|
||||||
|
let result =
|
||||||
|
crate::webdav::pull(url.as_str(), username.as_str(), password.as_str()).await;
|
||||||
|
let mapped = match result {
|
||||||
|
Ok(json) => Ok(json),
|
||||||
|
Err(err) => Err(format!("{}: {err:#}", t("下载失败", "Download failed"))),
|
||||||
|
};
|
||||||
|
*pull_result.lock().unwrap() = Some(mapped);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
{
|
{
|
||||||
let store = store.clone();
|
let store = store.clone();
|
||||||
window.on_sftp_name_column_width_changed(move |width_px: f32| {
|
window.on_sftp_name_column_width_changed(move |width_px: f32| {
|
||||||
|
|||||||
114
src/config.rs
114
src/config.rs
@@ -383,6 +383,23 @@ pub struct ConfigFile {
|
|||||||
/// FinalShell-style custom commands.
|
/// FinalShell-style custom commands.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub commands: Vec<StoredCommand>,
|
pub commands: Vec<StoredCommand>,
|
||||||
|
|
||||||
|
// --- WebDAV sync settings ------------------------------------------------
|
||||||
|
/// WebDAV server URL, e.g. "https://dav.example.com/meatshell/".
|
||||||
|
#[serde(default)]
|
||||||
|
pub webdav_url: String,
|
||||||
|
/// WebDAV username for authentication.
|
||||||
|
#[serde(default)]
|
||||||
|
pub webdav_username: String,
|
||||||
|
/// WebDAV password (encrypted at rest, decrypted in memory).
|
||||||
|
#[serde(default, skip_serializing_if = "Secret::is_empty")]
|
||||||
|
pub webdav_password: Secret,
|
||||||
|
/// WebDAV keychain reference for the password.
|
||||||
|
#[serde(default)]
|
||||||
|
pub webdav_password_ref: String,
|
||||||
|
/// Whether WebDAV sync is enabled.
|
||||||
|
#[serde(default)]
|
||||||
|
pub webdav_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ConfigStore {
|
pub struct ConfigStore {
|
||||||
@@ -653,6 +670,28 @@ impl ConfigStore {
|
|||||||
session.password = Secret::new(plain);
|
session.password = Secret::new(plain);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Load WebDAV password from keychain or encrypted fallback.
|
||||||
|
if !cfg.webdav_password_ref.is_empty() {
|
||||||
|
match crate::keychain::get_password(&cfg.webdav_password_ref) {
|
||||||
|
Ok(plain) => {
|
||||||
|
cfg.webdav_password = Secret::new(plain);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"failed to read WebDAV keychain secret: {err:#}"
|
||||||
|
);
|
||||||
|
if let Some(plain) =
|
||||||
|
Self::try_decrypt(&key, cfg.webdav_password.as_str())
|
||||||
|
{
|
||||||
|
cfg.webdav_password = Secret::new(plain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some(plain) =
|
||||||
|
Self::try_decrypt(&key, cfg.webdav_password.as_str())
|
||||||
|
{
|
||||||
|
cfg.webdav_password = Secret::new(plain);
|
||||||
|
}
|
||||||
Self::normalise_config(&mut cfg);
|
Self::normalise_config(&mut cfg);
|
||||||
cfg
|
cfg
|
||||||
}
|
}
|
||||||
@@ -1214,6 +1253,58 @@ impl ConfigStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── WebDAV sync settings ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn webdav_enabled(&self) -> bool {
|
||||||
|
self.cache.webdav_enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_webdav_enabled(&mut self, enabled: bool) {
|
||||||
|
self.cache.webdav_enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn webdav_url(&self) -> &str {
|
||||||
|
&self.cache.webdav_url
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_webdav_url(&mut self, url: String) {
|
||||||
|
self.cache.webdav_url = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn webdav_username(&self) -> &str {
|
||||||
|
&self.cache.webdav_username
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_webdav_username(&mut self, username: String) {
|
||||||
|
self.cache.webdav_username = username;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn webdav_password(&self) -> &Secret {
|
||||||
|
&self.cache.webdav_password
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_webdav_password(&mut self, password: Secret) {
|
||||||
|
self.cache.webdav_password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEBDAV_KEYCHAIN_PREFIX: &'static str = "webdav:";
|
||||||
|
|
||||||
|
fn webdav_keychain_ref() -> String {
|
||||||
|
format!("{}{}", Self::WEBDAV_KEYCHAIN_PREFIX, "default")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the in-memory config with a new one (used after WebDAV pull).
|
||||||
|
pub fn replace_config(&mut self, new_cfg: ConfigFile) {
|
||||||
|
// Preserve the WebDAV password from the current config so we don't
|
||||||
|
// overwrite it with whatever was in the remote file.
|
||||||
|
let old_webdav_password = self.cache.webdav_password.clone();
|
||||||
|
let old_webdav_password_ref = self.cache.webdav_password_ref.clone();
|
||||||
|
self.cache = new_cfg;
|
||||||
|
self.cache.webdav_password = old_webdav_password;
|
||||||
|
self.cache.webdav_password_ref = old_webdav_password_ref;
|
||||||
|
Self::normalise_config(&mut self.cache);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn save(&self) -> Result<()> {
|
pub fn save(&self) -> Result<()> {
|
||||||
// Build a disk copy where every non-empty password is either migrated
|
// Build a disk copy where every non-empty password is either migrated
|
||||||
// into the OS keychain (preferred) or encrypted as a fallback.
|
// into the OS keychain (preferred) or encrypted as a fallback.
|
||||||
@@ -1242,6 +1333,29 @@ impl ConfigStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Save WebDAV password to keychain or encrypted fallback.
|
||||||
|
if !disk.webdav_password.is_empty()
|
||||||
|
&& !disk.webdav_password.as_str().starts_with(Self::ENC_PREFIX)
|
||||||
|
{
|
||||||
|
let password_ref = if disk.webdav_password_ref.is_empty() {
|
||||||
|
Self::webdav_keychain_ref()
|
||||||
|
} else {
|
||||||
|
disk.webdav_password_ref.clone()
|
||||||
|
};
|
||||||
|
match crate::keychain::set_password(&password_ref, disk.webdav_password.as_str()) {
|
||||||
|
Ok(()) => {
|
||||||
|
disk.webdav_password_ref = password_ref;
|
||||||
|
disk.webdav_password = Secret::default();
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
tracing::warn!(
|
||||||
|
"failed to write WebDAV password to OS keychain; using encrypted config fallback: {err:#}"
|
||||||
|
);
|
||||||
|
let enc = Self::encrypt(&self.key, disk.webdav_password.as_str())?;
|
||||||
|
disk.webdav_password = Secret::new(enc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let raw = serde_json::to_string_pretty(&disk)?;
|
let raw = serde_json::to_string_pretty(&disk)?;
|
||||||
// Write to a sibling temp file then rename — cheap atomicity.
|
// Write to a sibling temp file then rename — cheap atomicity.
|
||||||
let tmp = self.path.with_extension("json.tmp");
|
let tmp = self.path.with_extension("json.tmp");
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ mod ssh;
|
|||||||
mod ssh_config;
|
mod ssh_config;
|
||||||
mod system;
|
mod system;
|
||||||
mod telnet;
|
mod telnet;
|
||||||
|
mod webdav;
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
let log_path = debug_log::init();
|
let log_path = debug_log::init();
|
||||||
|
|||||||
112
src/webdav.rs
Normal file
112
src/webdav.rs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
//! WebDAV sync support for configuration backup/restore.
|
||||||
|
//!
|
||||||
|
//! Provides functions to push (upload) and pull (download) the sessions.json
|
||||||
|
//! config file to/from a WebDAV server, enabling multi-device sync.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use reqwest::Client;
|
||||||
|
|
||||||
|
/// Test connection to a WebDAV server with the given credentials.
|
||||||
|
///
|
||||||
|
/// Sends a PROPFIND request to verify the URL and credentials are valid.
|
||||||
|
pub async fn test_connection(url: &str, username: &str, password: &str) -> Result<()> {
|
||||||
|
let client = build_client()?;
|
||||||
|
let url = normalize_url(url);
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.request(reqwest::Method::from_bytes(b"PROPFIND")?, &url)
|
||||||
|
.basic_auth(username, Some(password))
|
||||||
|
.header("Depth", "0")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to connect to WebDAV server")?;
|
||||||
|
|
||||||
|
if response.status().is_success() || response.status().as_u16() == 207 {
|
||||||
|
Ok(())
|
||||||
|
} else if response.status().as_u16() == 401 {
|
||||||
|
anyhow::bail!("authentication failed: invalid username or password")
|
||||||
|
} else if response.status().as_u16() == 404 {
|
||||||
|
anyhow::bail!("WebDAV path not found: {}", url)
|
||||||
|
} else {
|
||||||
|
anyhow::bail!(
|
||||||
|
"WebDAV server returned HTTP {}",
|
||||||
|
response.status().as_u16()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload (push) config JSON to the WebDAV server.
|
||||||
|
///
|
||||||
|
/// The `config_json` should be the raw content of sessions.json.
|
||||||
|
/// The file is uploaded as `{url}/sessions.json`.
|
||||||
|
pub async fn push(url: &str, username: &str, password: &str, config_json: &str) -> Result<()> {
|
||||||
|
let client = build_client()?;
|
||||||
|
let url = format!("{}sessions.json", normalize_url(url));
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.put(&url)
|
||||||
|
.basic_auth(username, Some(password))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(config_json.to_string())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to upload config to WebDAV server")?;
|
||||||
|
|
||||||
|
if response.status().is_success() || response.status().as_u16() == 201 {
|
||||||
|
Ok(())
|
||||||
|
} else if response.status().as_u16() == 401 {
|
||||||
|
anyhow::bail!("authentication failed: invalid username or password")
|
||||||
|
} else {
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
anyhow::bail!("WebDAV upload failed (HTTP {status}): {body}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download (pull) config JSON from the WebDAV server.
|
||||||
|
///
|
||||||
|
/// Returns the raw JSON content of sessions.json.
|
||||||
|
pub async fn pull(url: &str, username: &str, password: &str) -> Result<String> {
|
||||||
|
let client = build_client()?;
|
||||||
|
let url = format!("{}sessions.json", normalize_url(url));
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(&url)
|
||||||
|
.basic_auth(username, Some(password))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to download config from WebDAV server")?;
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
response
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.context("failed to read WebDAV response body")
|
||||||
|
} else if response.status().as_u16() == 401 {
|
||||||
|
anyhow::bail!("authentication failed: invalid username or password")
|
||||||
|
} else if response.status().as_u16() == 404 {
|
||||||
|
anyhow::bail!("no config found on WebDAV server (file not found)")
|
||||||
|
} else {
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
anyhow::bail!("WebDAV download failed (HTTP {status})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build an HTTP client with sensible defaults for WebDAV operations.
|
||||||
|
fn build_client() -> Result<Client> {
|
||||||
|
Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(30))
|
||||||
|
.redirect(reqwest::redirect::Policy::limited(5))
|
||||||
|
.build()
|
||||||
|
.context("failed to create HTTP client")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure the URL ends with a single trailing slash.
|
||||||
|
fn normalize_url(url: &str) -> String {
|
||||||
|
let url = url.trim();
|
||||||
|
if url.ends_with('/') {
|
||||||
|
url.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{url}/")
|
||||||
|
}
|
||||||
|
}
|
||||||
123
ui/app.slint
123
ui/app.slint
@@ -222,6 +222,16 @@ export component AppWindow inherits Window {
|
|||||||
in-out property <bool> ui-debug-ids: false;
|
in-out property <bool> ui-debug-ids: false;
|
||||||
in property <image> background-image;
|
in property <image> background-image;
|
||||||
in-out property <string> config-dir-text;
|
in-out property <string> config-dir-text;
|
||||||
|
// WebDAV sync settings
|
||||||
|
in-out property <bool> webdav-enabled: false;
|
||||||
|
in-out property <string> webdav-url-text;
|
||||||
|
in-out property <string> webdav-username-text;
|
||||||
|
in-out property <string> webdav-password-text;
|
||||||
|
in-out property <string> webdav-status-text;
|
||||||
|
callback webdav-test-connection();
|
||||||
|
callback webdav-push();
|
||||||
|
callback webdav-pull();
|
||||||
|
callback webdav-save-settings(bool, string, string, string);
|
||||||
callback app-config-save(string, string, string, int, string, string, string, bool, string);
|
callback app-config-save(string, string, string, int, string, string, string, bool, string);
|
||||||
callback app-config-pick-dir();
|
callback app-config-pick-dir();
|
||||||
callback app-background-pick();
|
callback app-background-pick();
|
||||||
@@ -1012,7 +1022,7 @@ export component AppWindow inherits Window {
|
|||||||
x: (parent.width - self.width) / 2;
|
x: (parent.width - self.width) / 2;
|
||||||
y: (parent.height - self.height) / 2;
|
y: (parent.height - self.height) / 2;
|
||||||
width: min(640px, parent.width - 48px);
|
width: min(640px, parent.width - 48px);
|
||||||
height: min(500px, parent.height - 48px);
|
height: min(620px, parent.height - 48px);
|
||||||
background: Theme.bg-panel;
|
background: Theme.bg-panel;
|
||||||
border-radius: Theme.radius-md;
|
border-radius: Theme.radius-md;
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
@@ -1301,6 +1311,112 @@ export component AppWindow inherits Window {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- WebDAV Sync Settings ---
|
||||||
|
Rectangle { height: 1px; background: Theme.border-subtle; }
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
height: 30px;
|
||||||
|
spacing: 10px;
|
||||||
|
Text {
|
||||||
|
width: 148px;
|
||||||
|
height: parent.height;
|
||||||
|
text: @tr("WebDAV sync");
|
||||||
|
color: Theme.text-secondary;
|
||||||
|
font-size: Theme.fs-sm;
|
||||||
|
font-weight: 600;
|
||||||
|
vertical-alignment: center;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
ConfigButton {
|
||||||
|
label: root.webdav-enabled ? @tr("On") : @tr("Off");
|
||||||
|
button-width: 76px;
|
||||||
|
selected: root.webdav-enabled;
|
||||||
|
clicked => {
|
||||||
|
root.webdav-enabled = !root.webdav-enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rectangle { horizontal-stretch: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
height: 30px;
|
||||||
|
spacing: 10px;
|
||||||
|
Text {
|
||||||
|
width: 148px;
|
||||||
|
height: parent.height;
|
||||||
|
text: @tr("WebDAV URL");
|
||||||
|
color: Theme.text-secondary;
|
||||||
|
font-size: Theme.fs-sm;
|
||||||
|
vertical-alignment: center;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
ConfigTextField {
|
||||||
|
value <=> root.webdav-url-text;
|
||||||
|
field-width: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
height: 30px;
|
||||||
|
spacing: 10px;
|
||||||
|
Text {
|
||||||
|
width: 148px;
|
||||||
|
height: parent.height;
|
||||||
|
text: @tr("WebDAV username");
|
||||||
|
color: Theme.text-secondary;
|
||||||
|
font-size: Theme.fs-sm;
|
||||||
|
vertical-alignment: center;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
ConfigTextField {
|
||||||
|
value <=> root.webdav-username-text;
|
||||||
|
field-width: 160px;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
width: 80px;
|
||||||
|
height: parent.height;
|
||||||
|
text: @tr("Password");
|
||||||
|
color: Theme.text-secondary;
|
||||||
|
font-size: Theme.fs-sm;
|
||||||
|
vertical-alignment: center;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
ConfigTextField {
|
||||||
|
value <=> root.webdav-password-text;
|
||||||
|
field-width: 140px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
height: 30px;
|
||||||
|
spacing: 10px;
|
||||||
|
Text {
|
||||||
|
width: 148px;
|
||||||
|
height: parent.height;
|
||||||
|
text: root.webdav-status-text;
|
||||||
|
color: root.webdav-status-text == @tr("Connection OK") ? Theme.success : Theme.text-muted;
|
||||||
|
font-size: Theme.fs-xs;
|
||||||
|
vertical-alignment: center;
|
||||||
|
overflow: elide;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
}
|
||||||
|
ConfigButton {
|
||||||
|
label: @tr("Test");
|
||||||
|
button-width: 72px;
|
||||||
|
clicked => { root.webdav-test-connection(); }
|
||||||
|
}
|
||||||
|
ConfigButton {
|
||||||
|
label: @tr("Upload");
|
||||||
|
button-width: 82px;
|
||||||
|
clicked => { root.webdav-push(); }
|
||||||
|
}
|
||||||
|
ConfigButton {
|
||||||
|
label: @tr("Download");
|
||||||
|
button-width: 82px;
|
||||||
|
clicked => { root.webdav-pull(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
height: 30px;
|
height: 30px;
|
||||||
spacing: 10px;
|
spacing: 10px;
|
||||||
@@ -1319,6 +1435,11 @@ export component AppWindow inherits Window {
|
|||||||
primary: true;
|
primary: true;
|
||||||
clicked => {
|
clicked => {
|
||||||
if root.ui-debug-ids { root.debug-click("configuration", "save"); }
|
if root.ui-debug-ids { root.debug-click("configuration", "save"); }
|
||||||
|
root.webdav-save-settings(
|
||||||
|
root.webdav-enabled,
|
||||||
|
root.webdav-url-text,
|
||||||
|
root.webdav-username-text,
|
||||||
|
root.webdav-password-text);
|
||||||
root.app-config-save(
|
root.app-config-save(
|
||||||
root.max-tabs-text,
|
root.max-tabs-text,
|
||||||
root.terminal-font-size-text,
|
root.terminal-font-size-text,
|
||||||
|
|||||||
Reference in New Issue
Block a user