自动同步
All checks were successful
Release / windows-x86_64-gnu (push) Successful in 1h5m41s

This commit is contained in:
2026-06-19 11:25:51 +08:00
Unverified
parent 6ce6598a0e
commit 0f8dfa71a9
6 changed files with 341 additions and 22 deletions

View File

@@ -142,11 +142,37 @@ jobs:
- name: Publish Gitea release - name: Publish Gitea release
if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main' if: startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main'
uses: https://gitea.com/actions/release-action@main env:
with: GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
files: ${{ env.ASSET }} run: |
tag_name: ${{ env.RELEASE_TAG }} set -eu
name: ${{ env.RELEASE_NAME }} TAG="${{ env.RELEASE_TAG }}"
api_key: ${{ secrets.GITHUB_TOKEN }} NAME="${{ env.RELEASE_NAME }}"
draft: false FILE="${{ env.ASSET }}"
prerelease: false # 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"

View File

@@ -41,6 +41,7 @@ zeroize = "1"
# (HTTP CONNECT proxies are handled in src/proxy.rs without an extra crate.) # (HTTP CONNECT proxies are handled in src/proxy.rs without an extra crate.)
tokio-socks = "0.5" tokio-socks = "0.5"
base64 = "0.22" base64 = "0.22"
urlencoding = "2"
# Cross-platform serial port access for the Serial session type (#14, #17). # Cross-platform serial port access for the Serial session type (#14, #17).
# Opens COM3 / /dev/ttyUSB0 etc.; on Linux the default `libudev` feature is # Opens COM3 / /dev/ttyUSB0 etc.; on Linux the default `libudev` feature is
# used for port enumeration (CI installs libudev-dev). # used for port enumeration (CI installs libudev-dev).

View File

@@ -451,6 +451,7 @@ pub fn run() -> Result<()> {
window.set_webdav_url_text(s.webdav_url().to_string().into()); 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_username_text(s.webdav_username().to_string().into());
window.set_webdav_password_text(s.webdav_password().as_str().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| { 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");
@@ -660,13 +661,15 @@ pub fn run() -> Result<()> {
{ {
let weak = window.as_weak(); let weak = window.as_weak();
let store = store.clone(); 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(); let mut s = store.borrow_mut();
s.set_webdav_enabled(enabled); s.set_webdav_enabled(enabled);
s.set_webdav_url(url.to_string()); s.set_webdav_url(url.to_string());
s.set_webdav_username(username.to_string()); s.set_webdav_username(username.to_string());
s.set_webdav_password(crate::config::Secret::new(password.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() { if let Err(err) = s.save() {
tracing::warn!("failed to save WebDAV settings: {err:#}"); 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_url_text(url);
w.set_webdav_username_text(username); w.set_webdav_username_text(username);
w.set_webdav_password_text(password); 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(); let weak2 = weak.clone();
rt.spawn(async move { rt.spawn(async move {
let result = // Generate timestamped backup filename
crate::webdav::push(url.as_str(), username.as_str(), password.as_str(), &config_json) 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; .await;
let push_result = match result {
Ok(()) => Ok(t("上传成功", "Upload OK").to_string()), if let Err(err) = result {
Err(err) => Err(format!("{}: {err:#}", t("上传失败", "Upload failed"))), let msg = format!("{}: {err:#}", t("备份上传失败", "Backup upload failed"));
};
let _ = slint::invoke_from_event_loop(move || { let _ = slint::invoke_from_event_loop(move || {
if let Some(w) = weak2.upgrade() { if let Some(w) = weak2.upgrade() {
let msg = match push_result { w.set_webdav_status_text(msg.into());
Ok(m) => m, }
Err(e) => e, });
}; 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<String> = 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 = format!(
"{} ({})",
t("备份上传成功", "Backup OK"),
backup_filename
);
w.set_webdav_status_text(msg.into()); 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<Mutex<Option<std::time::Instant>>> =
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(); 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| {

View File

@@ -400,6 +400,9 @@ pub struct ConfigFile {
/// Whether WebDAV sync is enabled. /// Whether WebDAV sync is enabled.
#[serde(default)] #[serde(default)]
pub webdav_enabled: bool, pub webdav_enabled: bool,
/// Auto-sync interval in minutes (0 = disabled).
#[serde(default)]
pub webdav_auto_sync_interval: u32,
} }
pub struct ConfigStore { pub struct ConfigStore {
@@ -1287,6 +1290,14 @@ impl ConfigStore {
self.cache.webdav_password = password; 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:"; const WEBDAV_KEYCHAIN_PREFIX: &'static str = "webdav:";
fn webdav_keychain_ref() -> String { fn webdav_keychain_ref() -> String {

View File

@@ -92,6 +92,110 @@ pub async fn pull(url: &str, username: &str, password: &str) -> Result<String> {
} }
} }
/// 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<Vec<String>> {
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 <d:href> or <D:href> or <href> 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 ["<D:href>", "<d:href>", "<href>"] {
for href in body.split(pattern).skip(1) {
if let Some(end) = href.find("</") {
let full_path = &href[..end];
// Convert URL-encoded path to plain filename
if let Some(name) = full_path.strip_prefix(&base) {
let name = name.trim_end_matches('/');
if !name.is_empty() {
let decoded = urlencoding::decode(name)
.unwrap_or(std::borrow::Cow::Borrowed(name));
files.push(decoded.into_owned());
}
}
}
}
}
Ok(files)
}
/// Delete a file from the WebDAV server.
pub async fn delete_file(url: &str, username: &str, password: &str, filename: &str) -> 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. /// Build an HTTP client with sensible defaults for WebDAV operations.
fn build_client() -> Result<Client> { fn build_client() -> Result<Client> {
Client::builder() Client::builder()

View File

@@ -228,10 +228,11 @@ export component AppWindow inherits Window {
in-out property <string> webdav-username-text; in-out property <string> webdav-username-text;
in-out property <string> webdav-password-text; in-out property <string> webdav-password-text;
in-out property <string> webdav-status-text; in-out property <string> webdav-status-text;
in-out property <string> webdav-auto-sync-interval: "0";
callback webdav-test-connection(); callback webdav-test-connection();
callback webdav-push(); callback webdav-push();
callback webdav-pull(); 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-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();
@@ -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 { HorizontalLayout {
height: 30px; height: 30px;
spacing: 10px; spacing: 10px;
@@ -1439,7 +1466,8 @@ export component AppWindow inherits Window {
root.webdav-enabled, root.webdav-enabled,
root.webdav-url-text, root.webdav-url-text,
root.webdav-username-text, root.webdav-username-text,
root.webdav-password-text); root.webdav-password-text,
root.webdav-auto-sync-interval);
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,