mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-30 18:33:04 +08:00
feat: 新增 S3 兼容云存储同步 (#1351)
* Add S3 Cloud Sync design document Design for adding AWS S3 as a new Cloud Sync backend alongside WebDAV. Hybrid approach: extract shared sync protocol, add independent S3 transport. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add S3 cloud sync implementation design (reqwest + Sig V4) Updated design based on 2026-03-06 draft: switches from rust-s3 crate to hand-rolled AWS Sig V4 on existing reqwest for broader S3-compatible service support (AWS, MinIO, R2, Alibaba OSS, Tencent COS, Huawei OBS). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add S3 cloud sync implementation plan (11 tasks, TDD) Detailed step-by-step plan covering: sync_protocol extraction, S3 Sig V4 transport, settings, sync/auto-sync modules, Tauri commands, frontend presets/dynamic form, and i18n. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * deps: add hmac crate for S3 Sig V4 signing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: extract sync_protocol.rs from webdav_sync.rs for shared use Move transport-agnostic sync protocol logic (constants, types, snapshot building, manifest validation, artifact verification, snapshot application, utilities) into a new shared sync_protocol module so both WebDAV and the upcoming S3 transport can reuse it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use transport-neutral error keys in sync_protocol Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add S3 transport layer with AWS Sig V4 signing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add S3SyncSettings to AppSettings Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add S3 sync module with upload/download/fetch Implements the S3 sync protocol layer (s3_sync.rs) that combines the shared sync_protocol with the S3 transport. Mirrors the WebDAV sync module structure with independent sync mutex, connection check, upload, download, fetch_remote_info, and sync status persistence. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add S3 auto sync worker with debounce Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add S3 sync Tauri commands and auto sync worker startup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add S3 sync TypeScript types and API layer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add S3 sync i18n translations (en/zh/ja) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add S3 sync presets and dynamic form to sync settings Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: preserve HTTP scheme for S3 custom endpoints (MinIO support) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add live S3 integration tests (env-var driven, --ignored) Run with: S3_TEST_AK=... S3_TEST_SK=... S3_TEST_BUCKET=... cargo test --lib services::s3::integration_tests -- --ignored Verifies test_connection, put_object, get_object, head_object, and 404 handling against a real S3 bucket using the project's own Sig V4 signing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: remove internal design docs before PR * fix: wire S3 auto-sync to DB hook & sync UI state on async load - P1: Add s3_auto_sync::notify_db_changed call in SQLite update_hook so S3 auto-sync worker receives DB change signals (was only wired for WebDAV, leaving S3 worker idle) - P2: Add useEffect to update syncType selector when s3Config loads asynchronously, preventing stale "webdav" default for S3 users * fix: satisfy clippy for s3 sync * fix: address s3 sync review feedback --------- Co-authored-by: Keith (via OpenClaw) <keithyt06@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
@@ -176,6 +176,106 @@ impl WebDavSyncSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// S3 同步设置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct S3SyncSettings {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub auto_sync: bool,
|
||||
#[serde(default)]
|
||||
pub region: String,
|
||||
#[serde(default)]
|
||||
pub bucket: String,
|
||||
#[serde(default)]
|
||||
pub access_key_id: String,
|
||||
#[serde(default)]
|
||||
pub secret_access_key: String,
|
||||
#[serde(default)]
|
||||
pub endpoint: String,
|
||||
#[serde(default = "default_remote_root")]
|
||||
pub remote_root: String,
|
||||
#[serde(default = "default_profile")]
|
||||
pub profile: String,
|
||||
#[serde(default)]
|
||||
pub status: WebDavSyncStatus,
|
||||
}
|
||||
|
||||
impl Default for S3SyncSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
auto_sync: false,
|
||||
region: String::new(),
|
||||
bucket: String::new(),
|
||||
access_key_id: String::new(),
|
||||
secret_access_key: String::new(),
|
||||
endpoint: String::new(),
|
||||
remote_root: default_remote_root(),
|
||||
profile: default_profile(),
|
||||
status: WebDavSyncStatus::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl S3SyncSettings {
|
||||
pub fn validate(&self) -> Result<(), crate::error::AppError> {
|
||||
if self.bucket.trim().is_empty() {
|
||||
return Err(crate::error::AppError::localized(
|
||||
"s3.bucket.required",
|
||||
"S3 存储桶不能为空",
|
||||
"S3 bucket is required.",
|
||||
));
|
||||
}
|
||||
if self.region.trim().is_empty() {
|
||||
return Err(crate::error::AppError::localized(
|
||||
"s3.region.required",
|
||||
"S3 区域不能为空",
|
||||
"S3 region is required.",
|
||||
));
|
||||
}
|
||||
if self.access_key_id.trim().is_empty() {
|
||||
return Err(crate::error::AppError::localized(
|
||||
"s3.access_key_id.required",
|
||||
"S3 Access Key ID 不能为空",
|
||||
"S3 Access Key ID is required.",
|
||||
));
|
||||
}
|
||||
if self.secret_access_key.trim().is_empty() {
|
||||
return Err(crate::error::AppError::localized(
|
||||
"s3.secret_access_key.required",
|
||||
"S3 Secret Access Key 不能为空",
|
||||
"S3 Secret Access Key is required.",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn normalize(&mut self) {
|
||||
self.region = self.region.trim().to_string();
|
||||
self.bucket = self.bucket.trim().to_string();
|
||||
self.access_key_id = self.access_key_id.trim().to_string();
|
||||
self.endpoint = self.endpoint.trim().to_string();
|
||||
self.remote_root = self.remote_root.trim().to_string();
|
||||
self.profile = self.profile.trim().to_string();
|
||||
if self.remote_root.is_empty() {
|
||||
self.remote_root = default_remote_root();
|
||||
}
|
||||
if self.profile.is_empty() {
|
||||
self.profile = default_profile();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if all credential fields are blank (no config to persist).
|
||||
fn is_empty(&self) -> bool {
|
||||
self.bucket.is_empty()
|
||||
&& self.region.is_empty()
|
||||
&& self.access_key_id.is_empty()
|
||||
&& self.secret_access_key.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 本机自动迁移状态。
|
||||
///
|
||||
/// 这里记录的是本机启动时执行过的一次性迁移;标记不随数据库同步。
|
||||
@@ -322,6 +422,10 @@ pub struct AppSettings {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub webdav_sync: Option<WebDavSyncSettings>,
|
||||
|
||||
// ===== S3 同步设置 =====
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub s3_sync: Option<S3SyncSettings>,
|
||||
|
||||
// ===== WebDAV 备份设置(旧版,保留向后兼容)=====
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub webdav_backup: Option<serde_json::Value>,
|
||||
@@ -392,6 +496,7 @@ impl Default for AppSettings {
|
||||
skill_sync_method: SyncMethod::default(),
|
||||
skill_storage_location: SkillStorageLocation::default(),
|
||||
webdav_sync: None,
|
||||
s3_sync: None,
|
||||
webdav_backup: None,
|
||||
backup_interval_hours: None,
|
||||
backup_retain_count: None,
|
||||
@@ -467,6 +572,13 @@ impl AppSettings {
|
||||
self.webdav_sync = None;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(s3) = &mut self.s3_sync {
|
||||
s3.normalize();
|
||||
if s3.is_empty() {
|
||||
self.s3_sync = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_from_file() -> Self {
|
||||
@@ -570,6 +682,9 @@ pub fn get_settings_for_frontend() -> AppSettings {
|
||||
if let Some(sync) = &mut settings.webdav_sync {
|
||||
sync.password.clear();
|
||||
}
|
||||
if let Some(s3) = &mut settings.s3_sync {
|
||||
s3.secret_access_key.clear();
|
||||
}
|
||||
settings.webdav_backup = None;
|
||||
settings
|
||||
}
|
||||
@@ -882,6 +997,26 @@ pub fn update_webdav_sync_status(status: WebDavSyncStatus) -> Result<(), AppErro
|
||||
})
|
||||
}
|
||||
|
||||
// ===== S3 同步设置管理函数 =====
|
||||
|
||||
pub fn get_s3_sync_settings() -> Option<S3SyncSettings> {
|
||||
settings_store().read().ok()?.s3_sync.clone()
|
||||
}
|
||||
|
||||
pub fn set_s3_sync_settings(settings: Option<S3SyncSettings>) -> Result<(), AppError> {
|
||||
mutate_settings(|current| {
|
||||
current.s3_sync = settings;
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_s3_sync_status(status: WebDavSyncStatus) -> Result<(), AppError> {
|
||||
mutate_settings(|current| {
|
||||
if let Some(s3) = current.s3_sync.as_mut() {
|
||||
s3.status = status;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user