V1
Some checks failed
Release / windows-x86_64-gnu (push) Failing after 3m53s

This commit is contained in:
2026-06-19 04:00:36 +08:00
Unverified
parent d32882443f
commit 0f07dd9f5b
9 changed files with 639 additions and 399 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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"

View File

@@ -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 "下载"

View File

@@ -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<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();
window.on_sftp_name_column_width_changed(move |width_px: f32| {

View File

@@ -383,6 +383,23 @@ pub struct ConfigFile {
/// FinalShell-style custom commands.
#[serde(default)]
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 {
@@ -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");

View File

@@ -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();

112
src/webdav.rs Normal file
View 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}/")
}
}

View File

@@ -222,6 +222,16 @@ export component AppWindow inherits Window {
in-out property <bool> ui-debug-ids: false;
in property <image> background-image;
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-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,