diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index f869ed0..2191038 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -11,19 +11,14 @@ on: 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 - + runs-on: ubuntu-latest + env: TARGET: x86_64-pc-windows-gnu BIN: meatshell.exe @@ -36,72 +31,24 @@ jobs: PKG_CONFIG_ALLOW_CROSS: "1" steps: - - name: 显示代理配置 - shell: bash + - name: Install system dependencies 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" - + sudo dpkg --add-architecture i386 + sudo apt-get update + sudo apt-get install -y \ + gcc-mingw-w64-x86-64 \ + binutils-mingw-w64-x86-64 \ + wine64 wine32 wine \ + xvfb \ + zip + sudo apt-get clean + - uses: actions/checkout@v4 with: clean: false - + - name: Cache Cargo registry and target - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: | ~/.cargo/registry @@ -112,51 +59,17 @@ jobs: ${{ runner.os }}-${{ env.TARGET }}-cargo- ${{ runner.os }}-cargo- - - name: Configure local Cargo cache - shell: bash + - name: Install Rust toolchain 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 + rustup target add "$TARGET" - 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 + - name: Fetch dependencies run: | for i in 1 2 3 4 5; do echo "Attempt $i to fetch dependencies..." @@ -172,45 +85,11 @@ jobs: 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 + cargo build --release --target "$TARGET" - name: Package Windows zip - shell: bash env: EVENT_NAME: ${{ gitea.event_name }} REF: ${{ gitea.ref }} @@ -218,9 +97,9 @@ jobs: 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}}" + event_name="${EVENT_NAME:-}" + ref="${REF:-}" + ref_name="${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}" @@ -238,263 +117,18 @@ jobs: 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 + zip -r "${STAGE}.zip" "$STAGE" 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" + if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main' + uses: https://gitea.com/actions/release-action@main + with: + files: ${{ env.ASSET }} + tag_name: ${{ env.RELEASE_TAG }} + name: ${{ env.RELEASE_NAME }} + api_key: ${{ secrets.GITHUB_TOKEN }} + draft: false + prerelease: false diff --git a/Cargo.toml b/Cargo.toml index 6dbb690..22c2d31 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,8 @@ 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"] } +# 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). # On Windows the icon comes from the .ico embedded by winresource at link time; diff --git a/lang/en/LC_MESSAGES/meatshell.po b/lang/en/LC_MESSAGES/meatshell.po index cdc0161..2b86ac2 100644 --- a/lang/en/LC_MESSAGES/meatshell.po +++ b/lang/en/LC_MESSAGES/meatshell.po @@ -353,3 +353,24 @@ msgstr "Off" msgid "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" diff --git a/lang/zh/LC_MESSAGES/meatshell.po b/lang/zh/LC_MESSAGES/meatshell.po index 6ac5972..04eccbe 100644 --- a/lang/zh/LC_MESSAGES/meatshell.po +++ b/lang/zh/LC_MESSAGES/meatshell.po @@ -353,3 +353,24 @@ msgstr "关闭" msgid "Shows stable ids on UI parts for bug reports" 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 "下载" diff --git a/src/app.rs b/src/app.rs index aca7aa2..e050b67 100644 --- a/src/app.rs +++ b/src/app.rs @@ -444,6 +444,14 @@ pub fn run() -> Result<()> { .to_string() .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| { let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"); 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>>> = + 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::(&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(); window.on_sftp_name_column_width_changed(move |width_px: f32| { diff --git a/src/config.rs b/src/config.rs index 62edc7a..592fae7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -383,6 +383,23 @@ pub struct ConfigFile { /// FinalShell-style custom commands. #[serde(default)] pub commands: Vec, + + // --- 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 { @@ -653,6 +670,28 @@ impl ConfigStore { 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); 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<()> { // Build a disk copy where every non-empty password is either migrated // 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)?; // Write to a sibling temp file then rename — cheap atomicity. let tmp = self.path.with_extension("json.tmp"); diff --git a/src/main.rs b/src/main.rs index e513512..0a6b9e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ mod ssh; mod ssh_config; mod system; mod telnet; +mod webdav; fn main() -> anyhow::Result<()> { let log_path = debug_log::init(); diff --git a/src/webdav.rs b/src/webdav.rs new file mode 100644 index 0000000..08212a8 --- /dev/null +++ b/src/webdav.rs @@ -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 { + 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::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}/") + } +} diff --git a/ui/app.slint b/ui/app.slint index 5bdf928..fc18e3c 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -222,6 +222,16 @@ export component AppWindow inherits Window { in-out property ui-debug-ids: false; in property background-image; in-out property config-dir-text; + // WebDAV sync settings + in-out property webdav-enabled: false; + in-out property webdav-url-text; + in-out property webdav-username-text; + in-out property webdav-password-text; + in-out property 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-pick-dir(); callback app-background-pick(); @@ -1012,7 +1022,7 @@ export component AppWindow inherits Window { x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; width: min(640px, parent.width - 48px); - height: min(500px, parent.height - 48px); + height: min(620px, parent.height - 48px); background: Theme.bg-panel; border-radius: Theme.radius-md; 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 { height: 30px; spacing: 10px; @@ -1319,6 +1435,11 @@ export component AppWindow inherits Window { primary: true; clicked => { 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.max-tabs-text, root.terminal-font-size-text,