From 0f8dfa71a90b4b02f06b98efeb16448bd26f005c Mon Sep 17 00:00:00 2001 From: Hsdi Date: Fri, 19 Jun 2026 11:25:51 +0800 Subject: [PATCH] =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/release.yml | 42 +++++++-- Cargo.toml | 1 + src/app.rs | 173 ++++++++++++++++++++++++++++++++--- src/config.rs | 11 +++ src/webdav.rs | 104 +++++++++++++++++++++ ui/app.slint | 32 ++++++- 6 files changed, 341 insertions(+), 22 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 5d12072..7e2a113 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -142,11 +142,37 @@ jobs: - name: Publish Gitea release 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 + env: + GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -eu + TAG="${{ env.RELEASE_TAG }}" + NAME="${{ env.RELEASE_NAME }}" + FILE="${{ env.ASSET }}" + # Try GITHUB_SERVER_URL first, fall back to GITEA_URL or localhost + BASE_URL="${GITHUB_SERVER_URL:-${GITEA_URL:-http://localhost:3000}}" + API="${BASE_URL}/api/v1" + REPO="${GITHUB_REPOSITORY:-$(git remote get-url origin | sed 's#.*[:/]\([^/]*/[^/]*\)\.git#\1#')}" + echo "Creating release ${TAG} on ${API}/repos/${REPO}..." + RELEASE_ID=$(curl -s -w '%{http_code}' -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + "${API}/repos/${REPO}/releases" \ + -d "{\"tag_name\":\"${TAG}\",\"name\":\"${NAME}\",\"draft\":false,\"prerelease\":false}" \ + -o /tmp/release.json | tail -c3) + if [ "$RELEASE_ID" = "409" ]; then + echo "Release ${TAG} already exists, updating..." + curl -s -X PATCH \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + "${API}/repos/${REPO}/releases/tags/${TAG}" \ + -d "{\"name\":\"${NAME}\"}" > /dev/null + fi + ATTACH_URL="${API}/repos/${REPO}/releases/tags/${TAG}/assets" + echo "Uploading ${FILE} to ${ATTACH_URL}..." + curl -s -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/zip" \ + --data-binary "@${FILE}" \ + "${ATTACH_URL}?filename=$(basename ${FILE})" + echo "✅ Release ${TAG} published" diff --git a/Cargo.toml b/Cargo.toml index 22c2d31..47b0b73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ zeroize = "1" # (HTTP CONNECT proxies are handled in src/proxy.rs without an extra crate.) tokio-socks = "0.5" base64 = "0.22" +urlencoding = "2" # Cross-platform serial port access for the Serial session type (#14, #17). # Opens COM3 / /dev/ttyUSB0 etc.; on Linux the default `libudev` feature is # used for port enumeration (CI installs libudev-dev). diff --git a/src/app.rs b/src/app.rs index e050b67..d4fb89e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -451,6 +451,7 @@ pub fn run() -> Result<()> { 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.set_webdav_auto_sync_interval(s.webdav_auto_sync_interval().to_string().into()); } window.on_debug_click(move |parent, button| { let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f"); @@ -660,13 +661,15 @@ pub fn run() -> Result<()> { { let weak = window.as_weak(); let store = store.clone(); - window.on_webdav_save_settings(move |enabled, url, username, password| { + window.on_webdav_save_settings(move |enabled, url, username, password, auto_sync_interval| { + let interval: u32 = auto_sync_interval.to_string().trim().parse().unwrap_or(0); { 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())); + s.set_webdav_auto_sync_interval(interval); if let Err(err) = s.save() { tracing::warn!("failed to save WebDAV settings: {err:#}"); } @@ -676,6 +679,7 @@ pub fn run() -> Result<()> { w.set_webdav_url_text(url); w.set_webdav_username_text(username); w.set_webdav_password_text(password); + w.set_webdav_auto_sync_interval(auto_sync_interval); } }); } @@ -749,19 +753,74 @@ pub fn run() -> Result<()> { } 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"))), - }; + // Generate timestamped backup filename + let now = chrono::Local::now(); + let timestamp = now.format("%Y-%m-%d_%H-%M-%S").to_string(); + let backup_filename = format!("sessions_{}.json", timestamp); + + // Push timestamped backup + let result = crate::webdav::push_backup( + url.as_str(), + username.as_str(), + password.as_str(), + &config_json, + &backup_filename, + ) + .await; + + if let Err(err) = result { + let msg = format!("{}: {err:#}", t("备份上传失败", "Backup upload failed")); + let _ = slint::invoke_from_event_loop(move || { + if let Some(w) = weak2.upgrade() { + w.set_webdav_status_text(msg.into()); + } + }); + return; + } + + // Clean up old backups: keep only 10 most recent + if let Ok(files) = crate::webdav::list_files( + url.as_str(), + username.as_str(), + password.as_str(), + ) + .await + { + // Filter for timestamped backup files + let mut backups: Vec = files + .iter() + .filter(|f| { + f.starts_with("sessions_") && f.ends_with(".json") && *f != "sessions.json" + }) + .cloned() + .collect(); + + // Sort by timestamp (newest first) — timestamp format is lexicographically sortable + backups.sort_by(|a, b| b.cmp(a)); + + // Delete oldest backups beyond 10 + let max_backups = 10; + if backups.len() > max_backups { + let to_delete = &backups[max_backups..]; + for file in to_delete { + let _ = crate::webdav::delete_file( + url.as_str(), + username.as_str(), + password.as_str(), + file, + ) + .await; + } + } + } + let _ = slint::invoke_from_event_loop(move || { if let Some(w) = weak2.upgrade() { - let msg = match push_result { - Ok(m) => m, - Err(e) => e, - }; + let msg = format!( + "{} ({})", + t("备份上传成功", "Backup OK"), + backup_filename + ); w.set_webdav_status_text(msg.into()); } }); @@ -862,6 +921,96 @@ pub fn run() -> Result<()> { }); }); } + + // --- WebDAV auto-sync timer ------------------------------------------- + // Periodically push the config to the WebDAV server when auto-sync is + // enabled. The timer fires every minute; the actual push only happens + // when the configured interval has elapsed since the last push. + { + let store = store.clone(); + let rt = runtime.clone(); + let auto_sync_last: Arc>> = + Arc::new(Mutex::new(None)); + let auto_sync_timer = slint::Timer::default(); + auto_sync_timer.start( + slint::TimerMode::Repeated, + std::time::Duration::from_secs(60), + move || { + let (enabled, url, username, password, interval) = { + let s = store.borrow(); + ( + s.webdav_enabled(), + s.webdav_url().to_string(), + s.webdav_username().to_string(), + s.webdav_password().as_str().to_string(), + s.webdav_auto_sync_interval(), + ) + }; + if !enabled || url.is_empty() || interval == 0 { + return; + } + let should_sync = { + let mut last = auto_sync_last.lock().unwrap(); + match *last { + Some(t) => { + if t.elapsed().as_secs() >= (interval as u64) * 60 { + *last = Some(std::time::Instant::now()); + true + } else { + false + } + } + None => { + *last = Some(std::time::Instant::now()); + true + } + } + }; + if !should_sync { + return; + } + // Save config then read the file. + { + let s = store.borrow(); + if let Err(err) = s.save() { + tracing::warn!("auto-sync: failed to save config: {err:#}"); + return; + } + } + 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) => { + tracing::warn!("auto-sync: failed to read config: {err:#}"); + return; + } + }; + tracing::info!("WebDAV auto-sync: pushing config (interval={}min)", interval); + let url_clone = url.clone(); + let username_clone = username.clone(); + let password_clone = password.clone(); + rt.spawn(async move { + let result = crate::webdav::push( + &url_clone, + &username_clone, + &password_clone, + &config_json, + ) + .await; + match result { + Ok(()) => tracing::info!("WebDAV auto-sync: push OK"), + Err(err) => tracing::warn!("WebDAV auto-sync: push failed: {err:#}"), + } + }); + }, + ); + // Keep the auto-sync timer alive by leaking it. + std::mem::forget(auto_sync_timer); + } + { 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 592fae7..242e06c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -400,6 +400,9 @@ pub struct ConfigFile { /// Whether WebDAV sync is enabled. #[serde(default)] pub webdav_enabled: bool, + /// Auto-sync interval in minutes (0 = disabled). + #[serde(default)] + pub webdav_auto_sync_interval: u32, } pub struct ConfigStore { @@ -1287,6 +1290,14 @@ impl ConfigStore { self.cache.webdav_password = password; } + pub fn webdav_auto_sync_interval(&self) -> u32 { + self.cache.webdav_auto_sync_interval + } + + pub fn set_webdav_auto_sync_interval(&mut self, minutes: u32) { + self.cache.webdav_auto_sync_interval = minutes; + } + const WEBDAV_KEYCHAIN_PREFIX: &'static str = "webdav:"; fn webdav_keychain_ref() -> String { diff --git a/src/webdav.rs b/src/webdav.rs index 08212a8..f5f56b2 100644 --- a/src/webdav.rs +++ b/src/webdav.rs @@ -92,6 +92,110 @@ pub async fn pull(url: &str, username: &str, password: &str) -> Result { } } +/// Upload (push) config JSON to the WebDAV server with a custom filename. +/// +/// Used for timestamped manual backups, e.g. `sessions_2024-01-15_10-30-00.json`. +pub async fn push_backup( + url: &str, + username: &str, + password: &str, + config_json: &str, + filename: &str, +) -> Result<()> { + let client = build_client()?; + let url = format!("{}{}", normalize_url(url), filename); + + 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 backup 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 backup upload failed (HTTP {status}): {body}") + } +} + +/// List files in the WebDAV directory (depth=1). +/// +/// Returns a list of filenames (not full paths) found at the given URL. +pub async fn list_files(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", "1") + .send() + .await + .context("failed to list WebDAV directory")?; + + if !response.status().is_success() && response.status().as_u16() != 207 { + let status = response.status().as_u16(); + anyhow::bail!("WebDAV PROPFIND failed (HTTP {status})"); + } + + let body = response + .text() + .await + .context("failed to read WebDAV PROPFIND body")?; + + // Simple XML parsing: extract all or or values + // and convert them to filenames. + let base = normalize_url(&url); + let mut files = Vec::new(); + // Match various namespace prefixes for href + for pattern in ["", "", ""] { + for href in body.split(pattern).skip(1) { + if let Some(end) = href.find(" Result<()> { + let client = build_client()?; + let url = format!("{}{}", normalize_url(url), filename); + + let response = client + .delete(&url) + .basic_auth(username, Some(password)) + .send() + .await + .context("failed to delete file from WebDAV server")?; + + if response.status().is_success() || response.status().as_u16() == 204 { + Ok(()) + } else if response.status().as_u16() == 401 { + anyhow::bail!("authentication failed: invalid username or password") + } else { + let status = response.status().as_u16(); + anyhow::bail!("WebDAV delete failed (HTTP {status})") + } +} + /// Build an HTTP client with sensible defaults for WebDAV operations. fn build_client() -> Result { Client::builder() diff --git a/ui/app.slint b/ui/app.slint index fc18e3c..0e6b50c 100644 --- a/ui/app.slint +++ b/ui/app.slint @@ -228,10 +228,11 @@ export component AppWindow inherits Window { in-out property webdav-username-text; in-out property webdav-password-text; in-out property webdav-status-text; + in-out property webdav-auto-sync-interval: "0"; callback webdav-test-connection(); callback webdav-push(); callback webdav-pull(); - callback webdav-save-settings(bool, string, string, string); + callback webdav-save-settings(bool, string, 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(); @@ -1387,6 +1388,32 @@ export component AppWindow inherits Window { } } + HorizontalLayout { + height: 30px; + spacing: 10px; + Text { + width: 148px; + height: parent.height; + text: @tr("Auto-sync interval (min)"); + color: Theme.text-secondary; + font-size: Theme.fs-sm; + vertical-alignment: center; + overflow: elide; + } + ConfigTextField { + value <=> root.webdav-auto-sync-interval; + field-width: 80px; + } + Text { + height: parent.height; + text: @tr("0 = off"); + color: Theme.text-muted; + font-size: Theme.fs-xs; + vertical-alignment: center; + overflow: elide; + } + } + HorizontalLayout { height: 30px; spacing: 10px; @@ -1439,7 +1466,8 @@ export component AppWindow inherits Window { root.webdav-enabled, root.webdav-url-text, root.webdav-username-text, - root.webdav-password-text); + root.webdav-password-text, + root.webdav-auto-sync-interval); root.app-config-save( root.max-tabs-text, root.terminal-font-size-text,