From 2a24da517f076d4eec86de5e6c56e08c1b4d8e94 Mon Sep 17 00:00:00 2001
From: Keith Yu <103325445+keithyt06@users.noreply.github.com>
Date: Thu, 4 Jun 2026 22:18:51 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20S3=20=E5=85=BC?=
=?UTF-8?q?=E5=AE=B9=E4=BA=91=E5=AD=98=E5=82=A8=E5=90=8C=E6=AD=A5=20(#1351?=
=?UTF-8?q?)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* 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
* 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
* 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
* deps: add hmac crate for S3 Sig V4 signing
Co-Authored-By: Claude Opus 4.6
* 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
* fix: use transport-neutral error keys in sync_protocol
Co-Authored-By: Claude Opus 4.6
* feat: add S3 transport layer with AWS Sig V4 signing
Co-Authored-By: Claude Opus 4.6
* feat: add S3SyncSettings to AppSettings
Co-Authored-By: Claude Opus 4.6
* 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
* feat: add S3 auto sync worker with debounce
Co-Authored-By: Claude Opus 4.6
* feat: add S3 sync Tauri commands and auto sync worker startup
Co-Authored-By: Claude Opus 4.6
* feat: add S3 sync TypeScript types and API layer
Co-Authored-By: Claude Opus 4.6
* feat: add S3 sync i18n translations (en/zh/ja)
Co-Authored-By: Claude Opus 4.6
* feat: add S3 sync presets and dynamic form to sync settings
Co-Authored-By: Claude Opus 4.6
* fix: preserve HTTP scheme for S3 custom endpoints (MinIO support)
Co-Authored-By: Claude Opus 4.6
* 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
* 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)
Co-authored-by: Claude Opus 4.6
Co-authored-by: Jason
---
src-tauri/Cargo.lock | 1 +
src-tauri/Cargo.toml | 1 +
src-tauri/src/commands/mod.rs | 2 +
src-tauri/src/commands/s3_sync.rs | 352 +++++
src-tauri/src/commands/settings.rs | 74 +-
src-tauri/src/database/mod.rs | 1 +
src-tauri/src/lib.rs | 9 +
src-tauri/src/services/mod.rs | 4 +
src-tauri/src/services/s3.rs | 926 +++++++++++
src-tauri/src/services/s3_auto_sync.rs | 270 ++++
src-tauri/src/services/s3_sync.rs | 319 ++++
src-tauri/src/services/sync_protocol.rs | 648 ++++++++
src-tauri/src/services/webdav_sync.rs | 571 +------
src-tauri/src/services/webdav_sync/archive.rs | 14 +-
src-tauri/src/settings.rs | 135 ++
src/App.tsx | 20 +-
src/components/settings/SettingsPage.tsx | 1 +
src/components/settings/WebdavSyncSection.tsx | 1348 ++++++++++++++---
src/hooks/useSettings.ts | 14 +-
src/i18n/locales/en.json | 101 ++
src/i18n/locales/ja.json | 101 ++
src/i18n/locales/zh-TW.json | 101 ++
src/i18n/locales/zh.json | 101 ++
src/lib/api/settings.ts | 41 +-
src/types.ts | 17 +
tests/integration/App.test.tsx | 16 +
26 files changed, 4393 insertions(+), 795 deletions(-)
create mode 100644 src-tauri/src/commands/s3_sync.rs
create mode 100644 src-tauri/src/services/s3.rs
create mode 100644 src-tauri/src/services/s3_auto_sync.rs
create mode 100644 src-tauri/src/services/s3_sync.rs
create mode 100644 src-tauri/src/services/sync_protocol.rs
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 243ccf74f..a54f8fac3 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -749,6 +749,7 @@ dependencies = [
"dirs 5.0.1",
"flate2",
"futures",
+ "hmac",
"http",
"http-body",
"http-body-util",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 10fc8c534..bb230c25e 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -77,6 +77,7 @@ indexmap = { version = "2", features = ["serde"] }
rust_decimal = "1.33"
uuid = { version = "1.11", features = ["v4"] }
sha2 = "0.10"
+hmac = "0.12"
json5 = "0.4"
json-five = "0.3.1"
diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs
index 6b8aff027..c41d7b44b 100644
--- a/src-tauri/src/commands/mod.rs
+++ b/src-tauri/src/commands/mod.rs
@@ -29,6 +29,7 @@ mod subscription;
mod sync_support;
mod lightweight;
+mod s3_sync;
mod usage;
mod webdav_sync;
mod workspace;
@@ -61,6 +62,7 @@ pub use stream_check::*;
pub use subscription::*;
pub use lightweight::*;
+pub use s3_sync::*;
pub use usage::*;
pub use webdav_sync::*;
pub use workspace::*;
diff --git a/src-tauri/src/commands/s3_sync.rs b/src-tauri/src/commands/s3_sync.rs
new file mode 100644
index 000000000..462b20439
--- /dev/null
+++ b/src-tauri/src/commands/s3_sync.rs
@@ -0,0 +1,352 @@
+#![allow(non_snake_case)]
+
+use serde_json::{json, Value};
+use tauri::State;
+
+use crate::commands::sync_support::{
+ attach_warning, post_sync_warning_from_result, run_post_import_sync,
+};
+use crate::error::AppError;
+use crate::services::s3_sync as s3_sync_service;
+use crate::settings::{self, S3SyncSettings};
+use crate::store::AppState;
+
+fn persist_sync_error(settings: &mut S3SyncSettings, error: &AppError, source: &str) {
+ settings.status.last_error = Some(error.to_string());
+ settings.status.last_error_source = Some(source.to_string());
+ let _ = settings::update_s3_sync_status(settings.status.clone());
+}
+
+fn s3_not_configured_error() -> String {
+ AppError::localized(
+ "s3.sync.not_configured",
+ "未配置 S3 同步",
+ "S3 sync is not configured.",
+ )
+ .to_string()
+}
+
+fn s3_sync_disabled_error() -> String {
+ AppError::localized("s3.sync.disabled", "S3 同步未启用", "S3 sync is disabled.").to_string()
+}
+
+fn require_enabled_s3_settings() -> Result {
+ let settings = settings::get_s3_sync_settings().ok_or_else(s3_not_configured_error)?;
+ if !settings.enabled {
+ return Err(s3_sync_disabled_error());
+ }
+ Ok(settings)
+}
+
+fn resolve_secret_for_request(
+ mut incoming: S3SyncSettings,
+ existing: Option,
+ preserve_empty_secret: bool,
+) -> S3SyncSettings {
+ if let Some(existing_settings) = existing {
+ if preserve_empty_secret && incoming.secret_access_key.is_empty() {
+ incoming.secret_access_key = existing_settings.secret_access_key;
+ }
+ }
+ incoming
+}
+
+#[cfg(test)]
+fn s3_sync_mutex() -> &'static tokio::sync::Mutex<()> {
+ s3_sync_service::sync_mutex()
+}
+
+async fn run_with_s3_lock(operation: Fut) -> Result
+where
+ Fut: std::future::Future
-
- {/* Config fields */}
-
- {/* Service preset selector */}
-
-
-
-
+ {/* ─── Sync type selector ───────────────────────────── */}
+
+
+
+
- {/* Server URL */}
-
-
- updateField("baseUrl", e.target.value)}
- onBlur={handleBaseUrlBlur}
- placeholder={t("settings.webdavSync.baseUrlPlaceholder")}
- className="text-xs flex-1"
- disabled={isLoading}
- />
-
-
- {/* Username */}
-
-
- updateField("username", e.target.value)}
- placeholder={t("settings.webdavSync.usernamePlaceholder")}
- className="text-xs flex-1"
- disabled={isLoading}
- />
-
-
- {/* Password */}
-
-
- updateField("password", e.target.value)}
- placeholder={t("settings.webdavSync.passwordPlaceholder")}
- className="text-xs flex-1"
- autoComplete="off"
- disabled={isLoading}
- />
-
-
- {/* Preset hint */}
- {activePreset?.hint && (
-
-
-
{t(activePreset.hint)}
+ {/* ─── WebDAV form ──────────────────────────────────── */}
+ {syncType === "webdav" && (
+
+ {/* Config fields */}
+
+ {/* Service preset selector */}
+
+
+
- )}
- {/* Remote Root */}
-
-
- updateField("remoteRoot", e.target.value)}
- placeholder="cc-switch-sync"
- className="text-xs flex-1"
- disabled={isLoading}
- />
-
-
- {/* Profile */}
-
-
- updateField("profile", e.target.value)}
- placeholder="default"
- className="text-xs flex-1"
- disabled={isLoading}
- />
-
-
-
-
-
-
+
+ updateField("baseUrl", e.target.value)}
+ onBlur={handleBaseUrlBlur}
+ placeholder={t("settings.webdavSync.baseUrlPlaceholder")}
+ className="text-xs flex-1"
disabled={isLoading}
/>
+
+ {/* Username */}
+
+
+ updateField("username", e.target.value)}
+ placeholder={t("settings.webdavSync.usernamePlaceholder")}
+ className="text-xs flex-1"
+ disabled={isLoading}
+ />
+
+
+ {/* Password */}
+
+
+ updateField("password", e.target.value)}
+ placeholder={t("settings.webdavSync.passwordPlaceholder")}
+ className="text-xs flex-1"
+ autoComplete="off"
+ disabled={isLoading}
+ />
+
+
+ {/* Preset hint */}
+ {activePreset?.hint && (
+
+
+ {t(activePreset.hint)}
+
+ )}
+
+ {/* Remote Root */}
+
+
+ updateField("remoteRoot", e.target.value)}
+ placeholder="cc-switch-sync"
+ className="text-xs flex-1"
+ disabled={isLoading}
+ />
+
+
+ {/* Profile */}
+
+
+ updateField("profile", e.target.value)}
+ placeholder="default"
+ className="text-xs flex-1"
+ disabled={isLoading}
+ />
+
+
+
+
+
+
+
+
-
- {/* Last sync time */}
- {lastSyncDisplay && (
-
- {t("settings.webdavSync.lastSync", { time: lastSyncDisplay })}
-
- )}
- {showAutoSyncError && (
-
-
- {t("settings.webdavSync.autoSyncLastErrorTitle")}
+ {/* Last sync time */}
+ {lastSyncDisplay && (
+
+ {t("settings.webdavSync.lastSync", { time: lastSyncDisplay })}
-
{lastError}
-
- {t("settings.webdavSync.autoSyncLastErrorHint")}
-
-
- )}
-
- {/* Config buttons + save status */}
-
-
-
-
- {/* Save status indicator */}
- {dirty && (
-
-
- {t("settings.webdavSync.unsaved")}
-
)}
- {!dirty && justSaved && (
-
-
- {t("settings.webdavSync.saved")}
-
+ {showAutoSyncError && (
+
+
+ {t("settings.webdavSync.autoSyncLastErrorTitle")}
+
+
{lastError}
+
+ {t("settings.webdavSync.autoSyncLastErrorHint")}
+
+
+ )}
+
+ {/* Config buttons + save status */}
+
+
+
+
+ {/* Save status indicator */}
+ {dirty && (
+
+
+ {t("settings.webdavSync.unsaved")}
+
+ )}
+ {!dirty && justSaved && (
+
+
+ {t("settings.webdavSync.saved")}
+
+ )}
+
+
+ {/* Sync buttons */}
+
+ {!hasSavedConfig && (
+
+ {t("settings.webdavSync.saveBeforeSync")}
+
)}
+ )}
- {/* Sync buttons */}
-
-
-
+ {/* ─── S3 form ──────────────────────────────────────── */}
+ {syncType === "s3" && (
+
+
+ {/* S3 preset selector */}
+
+
+
+
+
+ {/* Preset hint */}
+ {activeS3Preset?.hint && (
+
+
+ {t(activeS3Preset.hint)}
+
+ )}
+
+ {/* Region */}
+
+
+ {
+ setS3Region(e.target.value);
+ markS3Dirty();
+ }}
+ placeholder={activeS3Preset?.regionPlaceholder ?? "us-east-1"}
+ className="text-xs flex-1"
+ disabled={isS3Loading}
+ />
+
+
+ {/* Bucket */}
+
+
+ {
+ setS3Bucket(e.target.value);
+ markS3Dirty();
+ }}
+ placeholder={t("settings.s3Sync.bucketPlaceholder")}
+ className="text-xs flex-1"
+ disabled={isS3Loading}
+ />
+
+
+ {/* Access Key ID */}
+
+
+ {
+ setS3AccessKeyId(e.target.value);
+ markS3Dirty();
+ }}
+ placeholder={t("settings.s3Sync.accessKeyIdPlaceholder")}
+ className="text-xs flex-1"
+ disabled={isS3Loading}
+ />
+
+
+ {/* Secret Access Key */}
+
+
+ {
+ setS3SecretAccessKey(e.target.value);
+ setS3SecretTouched(true);
+ markS3Dirty();
+ }}
+ placeholder={t("settings.s3Sync.secretAccessKeyPlaceholder")}
+ className="text-xs flex-1"
+ autoComplete="off"
+ disabled={isS3Loading}
+ />
+
+
+ {/* Endpoint (optional) */}
+
+
+ {
+ setS3Endpoint(e.target.value);
+ markS3Dirty();
+ }}
+ placeholder={t("settings.s3Sync.endpointPlaceholder")}
+ className="text-xs flex-1"
+ disabled={isS3Loading}
+ />
+
+
+ {/* Remote Root */}
+
+
+ {
+ setS3RemoteRoot(e.target.value);
+ markS3Dirty();
+ }}
+ placeholder="cc-switch-sync"
+ className="text-xs flex-1"
+ disabled={isS3Loading}
+ />
+
+
+ {/* Profile */}
+
+
+ {
+ setS3Profile(e.target.value);
+ markS3Dirty();
+ }}
+ placeholder="default"
+ className="text-xs flex-1"
+ disabled={isS3Loading}
+ />
+
+
+ {/* Auto Sync toggle */}
+
+
+
+ {
+ setS3AutoSync(checked);
+ markS3Dirty();
+ }}
+ aria-label={t("settings.s3Sync.autoSync")}
+ disabled={isS3Loading}
+ />
+
+
+
+ {/* Enabled toggle */}
+
+
+
+ {
+ setS3Enabled(checked);
+ markS3Dirty();
+ }}
+ aria-label={t("settings.s3Sync.enabled")}
+ disabled={isS3Loading}
+ />
+
+
+
+
+ {/* Last sync time */}
+ {s3LastSyncDisplay && (
+
+ {t("settings.s3Sync.lastSync", { time: s3LastSyncDisplay })}
+
+ )}
+ {s3ShowAutoSyncError && (
+
+
+ {t("settings.s3Sync.autoSyncLastErrorTitle")}
+
+
+ {s3LastError}
+
+
+ {t("settings.s3Sync.autoSyncLastErrorHint")}
+
+
+ )}
+
+ {/* Config buttons + save status */}
+
+
+
+
+ {/* Save status indicator */}
+ {s3Dirty && (
+
+
+ {t("settings.s3Sync.unsaved")}
+
+ )}
+ {!s3Dirty && s3JustSaved && (
+
+
+ {t("settings.s3Sync.saved")}
+
+ )}
+
+
+ {/* Sync buttons */}
+
+ {!hasS3SavedConfig && (
+
+ {t("settings.s3Sync.saveBeforeSync")}
+
+ )}
- {!hasSavedConfig && (
-
- {t("settings.webdavSync.saveBeforeSync")}
-
- )}
-
+ )}
- {/* ─── Upload confirmation dialog ──────────────────── */}
+ {/* ─── WebDAV Upload confirmation dialog ───────────── */}
- {/* ─── Download confirmation dialog ────────────────── */}
+ {/* ─── WebDAV Download confirmation dialog ─────────── */}
+ {/* ─── S3 Upload confirmation dialog ───────────────── */}
+
+
+ {/* ─── S3 Download confirmation dialog ─────────────── */}
+
+
+ {/* ─── Mutual exclusion confirmation dialog ────────── */}
+
+
{/* ─── Auto-sync confirmation dialog ────────────────── */}
.r2.cloudflarestorage.com. Region: 'auto'.",
+ "oss": "Alibaba Cloud OSS",
+ "ossHint": "Endpoint: https://oss-.aliyuncs.com. Region: cn-hangzhou, cn-shanghai, etc.",
+ "cos": "Tencent Cloud COS",
+ "cosHint": "Endpoint: https://cos..myqcloud.com. Region: ap-guangzhou, ap-beijing, etc.",
+ "obs": "Huawei OBS",
+ "obsHint": "Endpoint: https://obs..myhuaweicloud.com. Region: cn-north-4, cn-east-3, etc.",
+ "custom": "S3 Compatible (Custom)",
+ "customHint": "Any S3-compatible service. Custom endpoint required."
+ },
+ "region": "Region",
+ "regionPlaceholder": "e.g. us-east-1",
+ "bucket": "Bucket",
+ "bucketPlaceholder": "e.g. my-cc-switch-bucket",
+ "accessKeyId": "Access Key ID",
+ "accessKeyIdPlaceholder": "e.g. AKIA...",
+ "secretAccessKey": "Secret Access Key",
+ "secretAccessKeyPlaceholder": "Leave unchanged to keep the existing secret",
+ "endpoint": "Endpoint (optional for AWS)",
+ "endpointHint": "Required for most S3-compatible services",
+ "endpointPlaceholder": "e.g. https://s3-compatible.example.com",
+ "remoteRoot": "Remote Root Directory",
+ "remoteRootDefault": "Default: cc-switch-sync",
+ "profile": "Sync Profile Name",
+ "profileDefault": "Default: default",
+ "autoSync": "Auto Sync",
+ "autoSyncHint": "When enabled, each database change triggers an automatic S3 upload.",
+ "enabled": "Enable S3 Sync",
+ "enabledHint": "Turn on S3 sync for manual and automatic cloud operations.",
+ "mutualExclusionTitle": "Switch Sync Type",
+ "mutualExclusionMessage": "Enabling S3 sync will disable the current WebDAV sync. Continue?",
+ "mutualExclusionMessageReverse": "Enabling WebDAV sync will disable the current S3 sync. Continue?",
+ "mutualExclusionFailed": "Failed to switch sync type: {{error}}",
+ "testConnection": "Test Connection",
+ "connectionOk": "S3 connection successful",
+ "test": "Test Connection",
+ "testing": "Testing...",
+ "testSuccess": "S3 connection successful",
+ "testFailed": "S3 connection failed: {{error}}",
+ "save": "Save Config",
+ "saving": "Saving...",
+ "saveFailed": "Failed to save config: {{error}}",
+ "saveAndTestSuccess": "Config saved, S3 connection OK",
+ "saveAndTestFailed": "Config saved, but S3 connection test failed: {{error}}",
+ "upload": "Upload to Cloud",
+ "uploading": "Uploading...",
+ "uploadSuccess": "Uploaded to S3",
+ "uploadFailed": "S3 upload failed: {{error}}",
+ "autoSyncFailedToast": "S3 auto sync failed: {{error}}",
+ "download": "Download from Cloud",
+ "downloading": "Downloading...",
+ "downloadSuccess": "Downloaded and restored from S3",
+ "downloadFailed": "S3 download failed: {{error}}",
+ "lastSync": "Last sync: {{time}}",
+ "autoSyncLastErrorTitle": "Last S3 auto sync failed",
+ "autoSyncLastErrorHint": "Please check network or S3 settings. Auto sync will retry on future changes.",
+ "missingBucket": "Please enter the S3 bucket",
+ "noRemoteData": "No sync data found in the S3 bucket",
+ "incompatibleVersion": "Remote data is incompatible (version {{version}}). This client supports protocol v2 / db-v6.",
+ "unsaved": "Unsaved",
+ "saved": "Saved",
+ "unsavedChanges": "Please save config first",
+ "saveBeforeSync": "Save configuration first to enable upload/download.",
+ "fetchingRemote": "Fetching remote info...",
+ "fetchRemoteFailed": "Failed to fetch remote info. Please check configuration and network.",
+ "notConfigured": "S3 sync is not configured",
+ "disabled": "S3 sync is disabled",
+ "confirmDownload": {
+ "title": "Restore from S3",
+ "deviceName": "Uploaded by",
+ "createdAt": "Uploaded at",
+ "artifacts": "Contents",
+ "warning": "This will overwrite all local data and skill configurations",
+ "confirm": "Confirm Restore"
+ },
+ "confirmUpload": {
+ "title": "Upload to S3",
+ "content": "The following will be synced to the S3 bucket:",
+ "dbItem": "Database (all provider configs and data)",
+ "skillsItem": "Skills (all custom skills)",
+ "targetPath": "Target object path",
+ "existingData": "Existing cloud data",
+ "deviceName": "Uploaded by",
+ "createdAt": "Uploaded at",
+ "warning": "This will overwrite existing sync data at the S3 path",
+ "confirm": "Confirm Upload"
+ }
+ },
"autoReload": "Data refreshed",
"languageOptionChinese": "简体中文",
"languageOptionTraditionalChinese": "繁體中文",
diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json
index de28dd402..60bfc17e8 100644
--- a/src/i18n/locales/ja.json
+++ b/src/i18n/locales/ja.json
@@ -546,6 +546,107 @@
"confirm": "アップロードを実行"
}
},
+ "syncType": {
+ "label": "同期方式",
+ "webdav": "WebDAV",
+ "s3": "S3互換ストレージ"
+ },
+ "s3Sync": {
+ "presets": {
+ "label": "サービス",
+ "awsS3": "AWS S3",
+ "awsS3Hint": "IAM Access Keyを使用。s3:PutObject/GetObject/HeadObject権限が必要です。Region例: us-east-1, ap-northeast-1",
+ "minio": "MinIO",
+ "minioHint": "カスタムEndpointが必要です(例: http://minio.local:9000)。Regionは us-east-1 に設定可能",
+ "r2": "Cloudflare R2",
+ "r2Hint": "Endpoint: https://.r2.cloudflarestorage.com、Regionは auto に設定",
+ "oss": "Alibaba Cloud OSS",
+ "ossHint": "Endpoint: https://oss-.aliyuncs.com、Region例: cn-hangzhou, cn-shanghai",
+ "cos": "Tencent Cloud COS",
+ "cosHint": "Endpoint: https://cos..myqcloud.com、Region例: ap-guangzhou, ap-beijing",
+ "obs": "Huawei OBS",
+ "obsHint": "Endpoint: https://obs..myhuaweicloud.com、Region例: cn-north-4, cn-east-3",
+ "custom": "S3互換サービス(カスタム)",
+ "customHint": "任意のS3互換サービス。カスタムEndpointが必要です"
+ },
+ "region": "リージョン",
+ "regionPlaceholder": "例: us-east-1",
+ "bucket": "バケット",
+ "bucketPlaceholder": "例: my-cc-switch-bucket",
+ "accessKeyId": "Access Key ID",
+ "accessKeyIdPlaceholder": "例: AKIA...",
+ "secretAccessKey": "Secret Access Key",
+ "secretAccessKeyPlaceholder": "既存のシークレットを保持する場合は変更しないでください",
+ "endpoint": "エンドポイント(AWSは空欄可)",
+ "endpointHint": "多くの S3 互換サービスでは必須です",
+ "endpointPlaceholder": "例: https://s3-compatible.example.com",
+ "remoteRoot": "リモートルートディレクトリ",
+ "remoteRootDefault": "デフォルト: cc-switch-sync",
+ "profile": "同期プロファイル名",
+ "profileDefault": "デフォルト: default",
+ "autoSync": "自動同期",
+ "autoSyncHint": "有効にすると、データベース変更のたびに S3 へ自動アップロードします。",
+ "enabled": "S3 同期を有効化",
+ "enabledHint": "S3 のアップロード/ダウンロードと自動同期を有効にします。",
+ "mutualExclusionTitle": "同期方式の切り替え",
+ "mutualExclusionMessage": "S3同期を有効にすると、現在のWebDAV同期が無効になります。続行しますか?",
+ "mutualExclusionMessageReverse": "WebDAV同期を有効にすると、現在のS3同期が無効になります。続行しますか?",
+ "mutualExclusionFailed": "同期方式の切り替えに失敗しました:{{error}}",
+ "testConnection": "接続テスト",
+ "connectionOk": "S3接続成功",
+ "test": "接続テスト",
+ "testing": "テスト中...",
+ "testSuccess": "S3 接続成功",
+ "testFailed": "S3 接続失敗:{{error}}",
+ "save": "設定を保存",
+ "saving": "保存中...",
+ "saveFailed": "設定の保存に失敗しました:{{error}}",
+ "saveAndTestSuccess": "設定を保存しました。S3 接続正常です",
+ "saveAndTestFailed": "設定を保存しましたが、S3 接続テストに失敗しました:{{error}}",
+ "upload": "クラウドにアップロード",
+ "uploading": "アップロード中...",
+ "uploadSuccess": "S3 にアップロードしました",
+ "uploadFailed": "S3 アップロードに失敗しました:{{error}}",
+ "autoSyncFailedToast": "S3 自動同期に失敗しました:{{error}}",
+ "download": "クラウドからダウンロード",
+ "downloading": "ダウンロード中...",
+ "downloadSuccess": "S3 からダウンロード・復元しました",
+ "downloadFailed": "S3 ダウンロードに失敗しました:{{error}}",
+ "lastSync": "前回の同期:{{time}}",
+ "autoSyncLastErrorTitle": "前回の S3 自動同期に失敗しました",
+ "autoSyncLastErrorHint": "ネットワークまたは S3 設定を確認してください。次回の変更時に自動再試行されます。",
+ "missingBucket": "S3 バケットを入力してください",
+ "noRemoteData": "S3 バケットに同期データが見つかりません",
+ "incompatibleVersion": "リモートデータに互換性がありません(バージョン {{version}})。このクライアントは protocol v2 / db-v6 をサポートしています。",
+ "unsaved": "未保存",
+ "saved": "保存済み",
+ "unsavedChanges": "先に設定を保存してください",
+ "saveBeforeSync": "アップロード/ダウンロードを有効にするには、先に設定を保存してください。",
+ "fetchingRemote": "リモート情報を取得中...",
+ "fetchRemoteFailed": "リモート情報の取得に失敗しました。設定とネットワークを確認してください。",
+ "notConfigured": "S3同期が設定されていません",
+ "disabled": "S3同期が無効です",
+ "confirmDownload": {
+ "title": "S3 から復元",
+ "deviceName": "アップロード元",
+ "createdAt": "アップロード日時",
+ "artifacts": "内容",
+ "warning": "ローカルのすべてのデータとスキル設定が上書きされます",
+ "confirm": "復元を実行"
+ },
+ "confirmUpload": {
+ "title": "S3 にアップロード",
+ "content": "以下の内容を S3 バケットに同期します:",
+ "dbItem": "データベース(すべてのプロバイダー設定とデータ)",
+ "skillsItem": "スキル(すべてのカスタムスキル)",
+ "targetPath": "保存先オブジェクトパス",
+ "existingData": "クラウドの既存データ",
+ "deviceName": "アップロード元",
+ "createdAt": "アップロード日時",
+ "warning": "この S3 パスにある既存の同期データが上書きされます",
+ "confirm": "アップロードを実行"
+ }
+ },
"autoReload": "データを更新しました",
"languageOptionChinese": "简体中文",
"languageOptionTraditionalChinese": "繁體中文",
diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json
index 210ad69fd..bd603123f 100644
--- a/src/i18n/locales/zh-TW.json
+++ b/src/i18n/locales/zh-TW.json
@@ -546,6 +546,107 @@
"confirm": "確認上傳"
}
},
+ "syncType": {
+ "label": "同步方式",
+ "webdav": "WebDAV",
+ "s3": "S3 相容儲存"
+ },
+ "s3Sync": {
+ "presets": {
+ "label": "服務商",
+ "awsS3": "AWS S3",
+ "awsS3Hint": "使用 IAM Access Key,需要 s3:PutObject/GetObject/HeadObject 權限。Region 範例:us-east-1, ap-northeast-1",
+ "minio": "MinIO",
+ "minioHint": "需要自訂 Endpoint,如 http://minio.local:9000。Region 可設為 us-east-1",
+ "r2": "Cloudflare R2",
+ "r2Hint": "Endpoint: https://.r2.cloudflarestorage.com,Region 設為 auto",
+ "oss": "阿里雲 OSS",
+ "ossHint": "Endpoint: https://oss-.aliyuncs.com,Region 範例:cn-hangzhou, cn-shanghai",
+ "cos": "騰訊雲 COS",
+ "cosHint": "Endpoint: https://cos..myqcloud.com,Region 範例:ap-guangzhou, ap-beijing",
+ "obs": "華為雲 OBS",
+ "obsHint": "Endpoint: https://obs..myhuaweicloud.com,Region 範例:cn-north-4, cn-east-3",
+ "custom": "S3 相容服務(自訂)",
+ "customHint": "任意 S3 相容服務,需要自訂 Endpoint"
+ },
+ "region": "區域 (Region)",
+ "regionPlaceholder": "例如 us-east-1",
+ "bucket": "儲存桶 (Bucket)",
+ "bucketPlaceholder": "例如 my-cc-switch-bucket",
+ "accessKeyId": "Access Key ID",
+ "accessKeyIdPlaceholder": "例如 AKIA...",
+ "secretAccessKey": "Secret Access Key",
+ "secretAccessKeyPlaceholder": "留空則保持現有金鑰",
+ "endpoint": "Endpoint(AWS 可留空)",
+ "endpointHint": "多數 S3 相容服務需要填寫",
+ "endpointPlaceholder": "例如 https://s3-compatible.example.com",
+ "remoteRoot": "遠端根目錄",
+ "remoteRootDefault": "預設: cc-switch-sync",
+ "profile": "同步設定檔名稱",
+ "profileDefault": "預設: default",
+ "autoSync": "自動同步",
+ "autoSyncHint": "開啟後每次資料庫變更都會自動上傳到 S3。",
+ "enabled": "啟用 S3 同步",
+ "enabledHint": "開啟後可使用 S3 上傳/下載和自動同步。",
+ "mutualExclusionTitle": "切換同步方式",
+ "mutualExclusionMessage": "啟用 S3 同步將停用目前的 WebDAV 同步,是否繼續?",
+ "mutualExclusionMessageReverse": "啟用 WebDAV 同步將停用目前的 S3 同步,是否繼續?",
+ "mutualExclusionFailed": "切換同步方式失敗:{{error}}",
+ "testConnection": "測試連線",
+ "connectionOk": "S3 連線成功",
+ "test": "測試連線",
+ "testing": "測試中...",
+ "testSuccess": "S3 連線成功",
+ "testFailed": "S3 連線失敗:{{error}}",
+ "save": "儲存設定",
+ "saving": "儲存中...",
+ "saveFailed": "儲存設定失敗:{{error}}",
+ "saveAndTestSuccess": "設定已儲存,S3 連線正常",
+ "saveAndTestFailed": "設定已儲存,但 S3 連線測試失敗:{{error}}",
+ "upload": "上傳至雲端",
+ "uploading": "上傳中...",
+ "uploadSuccess": "已上傳到 S3",
+ "uploadFailed": "S3 上傳失敗:{{error}}",
+ "autoSyncFailedToast": "S3 自動同步失敗:{{error}}",
+ "download": "從雲端下載",
+ "downloading": "下載中...",
+ "downloadSuccess": "已從 S3 下載並還原",
+ "downloadFailed": "S3 下載失敗:{{error}}",
+ "lastSync": "上次同步:{{time}}",
+ "autoSyncLastErrorTitle": "上次 S3 自動同步失敗",
+ "autoSyncLastErrorHint": "請檢查網路或 S3 設定,系統會在後續變更時繼續自動重試。",
+ "missingBucket": "請填寫 S3 儲存桶",
+ "noRemoteData": "S3 儲存桶中沒有找到同步資料",
+ "incompatibleVersion": "遠端資料版本不相容(版本 {{version}}),目前支援協議 v2 / db-v6",
+ "unsaved": "未儲存",
+ "saved": "已儲存",
+ "unsavedChanges": "請先儲存設定",
+ "saveBeforeSync": "請先儲存設定,再使用上傳/下載。",
+ "fetchingRemote": "取得遠端資訊...",
+ "fetchRemoteFailed": "取得遠端資訊失敗,請檢查設定和網路後重試。",
+ "notConfigured": "未設定 S3 同步",
+ "disabled": "S3 同步未啟用",
+ "confirmDownload": {
+ "title": "從 S3 還原",
+ "deviceName": "上傳裝置",
+ "createdAt": "上傳時間",
+ "artifacts": "包含內容",
+ "warning": "還原將覆寫本地所有資料和技能設定",
+ "confirm": "確認還原"
+ },
+ "confirmUpload": {
+ "title": "上傳到 S3",
+ "content": "將同步以下內容到 S3 儲存桶:",
+ "dbItem": "資料庫(所有 Provider 設定和資料)",
+ "skillsItem": "技能包(所有自訂技能)",
+ "targetPath": "目標物件路徑",
+ "existingData": "雲端已有資料",
+ "deviceName": "上傳裝置",
+ "createdAt": "上傳時間",
+ "warning": "將覆寫該 S3 路徑下已有的同步資料",
+ "confirm": "確認上傳"
+ }
+ },
"autoReload": "資料已重新整理",
"languageOptionChinese": "简体中文",
"languageOptionTraditionalChinese": "繁體中文",
diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json
index 057958377..785474820 100644
--- a/src/i18n/locales/zh.json
+++ b/src/i18n/locales/zh.json
@@ -546,6 +546,107 @@
"confirm": "确认上传"
}
},
+ "syncType": {
+ "label": "同步方式",
+ "webdav": "WebDAV",
+ "s3": "S3 兼容存储"
+ },
+ "s3Sync": {
+ "presets": {
+ "label": "服务商",
+ "awsS3": "AWS S3",
+ "awsS3Hint": "使用 IAM Access Key,需要 s3:PutObject/GetObject/HeadObject 权限。Region 示例:us-east-1, ap-northeast-1",
+ "minio": "MinIO",
+ "minioHint": "需要自定义 Endpoint,如 http://minio.local:9000。Region 可设为 us-east-1",
+ "r2": "Cloudflare R2",
+ "r2Hint": "Endpoint: https://.r2.cloudflarestorage.com,Region 设为 auto",
+ "oss": "阿里云 OSS",
+ "ossHint": "Endpoint: https://oss-.aliyuncs.com,Region 示例:cn-hangzhou, cn-shanghai",
+ "cos": "腾讯云 COS",
+ "cosHint": "Endpoint: https://cos..myqcloud.com,Region 示例:ap-guangzhou, ap-beijing",
+ "obs": "华为云 OBS",
+ "obsHint": "Endpoint: https://obs..myhuaweicloud.com,Region 示例:cn-north-4, cn-east-3",
+ "custom": "S3 兼容服务(自定义)",
+ "customHint": "任意 S3 兼容服务,需要自定义 Endpoint"
+ },
+ "region": "区域 (Region)",
+ "regionPlaceholder": "例如 us-east-1",
+ "bucket": "存储桶 (Bucket)",
+ "bucketPlaceholder": "例如 my-cc-switch-bucket",
+ "accessKeyId": "Access Key ID",
+ "accessKeyIdPlaceholder": "例如 AKIA...",
+ "secretAccessKey": "Secret Access Key",
+ "secretAccessKeyPlaceholder": "留空则保持现有密钥",
+ "endpoint": "Endpoint(AWS 可留空)",
+ "endpointHint": "多数 S3 兼容服务需要填写",
+ "endpointPlaceholder": "例如 https://s3-compatible.example.com",
+ "remoteRoot": "远程根目录",
+ "remoteRootDefault": "默认: cc-switch-sync",
+ "profile": "同步配置名",
+ "profileDefault": "默认: default",
+ "autoSync": "自动同步",
+ "autoSyncHint": "开启后每次数据库变更都会自动上传到 S3。",
+ "enabled": "启用 S3 同步",
+ "enabledHint": "开启后可使用 S3 上传/下载和自动同步。",
+ "mutualExclusionTitle": "切换同步方式",
+ "mutualExclusionMessage": "启用 S3 同步将禁用当前的 WebDAV 同步,是否继续?",
+ "mutualExclusionMessageReverse": "启用 WebDAV 同步将禁用当前的 S3 同步,是否继续?",
+ "mutualExclusionFailed": "切换同步方式失败:{{error}}",
+ "testConnection": "测试连接",
+ "connectionOk": "S3 连接成功",
+ "test": "测试连接",
+ "testing": "测试中...",
+ "testSuccess": "S3 连接成功",
+ "testFailed": "S3 连接失败:{{error}}",
+ "save": "保存配置",
+ "saving": "保存中...",
+ "saveFailed": "保存配置失败:{{error}}",
+ "saveAndTestSuccess": "配置已保存,S3 连接正常",
+ "saveAndTestFailed": "配置已保存,但 S3 连接测试失败:{{error}}",
+ "upload": "上传到云端",
+ "uploading": "上传中...",
+ "uploadSuccess": "已上传到 S3",
+ "uploadFailed": "S3 上传失败:{{error}}",
+ "autoSyncFailedToast": "S3 自动同步失败:{{error}}",
+ "download": "从云端下载",
+ "downloading": "下载中...",
+ "downloadSuccess": "已从 S3 下载并恢复",
+ "downloadFailed": "S3 下载失败:{{error}}",
+ "lastSync": "上次同步:{{time}}",
+ "autoSyncLastErrorTitle": "上次 S3 自动同步失败",
+ "autoSyncLastErrorHint": "请检查网络或 S3 配置,系统会在后续变更时继续自动重试。",
+ "missingBucket": "请填写 S3 存储桶",
+ "noRemoteData": "S3 存储桶中没有找到同步数据",
+ "incompatibleVersion": "远端数据版本不兼容(版本 {{version}}),当前支持协议 v2 / db-v6",
+ "unsaved": "未保存",
+ "saved": "已保存",
+ "unsavedChanges": "请先保存配置",
+ "saveBeforeSync": "请先保存配置,再使用上传/下载。",
+ "fetchingRemote": "获取远端信息...",
+ "fetchRemoteFailed": "获取远端信息失败,请检查配置和网络后重试。",
+ "notConfigured": "未配置 S3 同步",
+ "disabled": "S3 同步未启用",
+ "confirmDownload": {
+ "title": "从 S3 恢复",
+ "deviceName": "上传设备",
+ "createdAt": "上传时间",
+ "artifacts": "包含内容",
+ "warning": "恢复将覆盖本地所有数据和技能配置",
+ "confirm": "确认恢复"
+ },
+ "confirmUpload": {
+ "title": "上传到 S3",
+ "content": "将同步以下内容到 S3 存储桶:",
+ "dbItem": "数据库(所有 Provider 配置和数据)",
+ "skillsItem": "技能包(所有自定义技能)",
+ "targetPath": "目标对象路径",
+ "existingData": "云端已有数据",
+ "deviceName": "上传设备",
+ "createdAt": "上传时间",
+ "warning": "将覆盖该 S3 路径下已有的同步数据",
+ "confirm": "确认上传"
+ }
+ },
"autoReload": "数据已刷新",
"languageOptionChinese": "简体中文",
"languageOptionTraditionalChinese": "繁體中文",
diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts
index c33e0052c..eb089e761 100644
--- a/src/lib/api/settings.ts
+++ b/src/lib/api/settings.ts
@@ -1,5 +1,10 @@
import { invoke } from "@tauri-apps/api/core";
-import type { Settings, WebDavSyncSettings, RemoteSnapshotInfo } from "@/types";
+import type {
+ Settings,
+ WebDavSyncSettings,
+ S3SyncSettings,
+ RemoteSnapshotInfo,
+} from "@/types";
import type { AppId } from "./types";
export interface ConfigTransferResult {
@@ -142,6 +147,40 @@ export const settingsApi = {
return await invoke("webdav_sync_fetch_remote_info");
},
+ // ===== S3 Sync API =====
+
+ async s3TestConnection(
+ settings: S3SyncSettings,
+ preserveEmptyPassword = true,
+ ): Promise {
+ return await invoke("s3_test_connection", {
+ settings,
+ preserveEmptyPassword,
+ });
+ },
+
+ async s3SyncUpload(): Promise {
+ return await invoke("s3_sync_upload");
+ },
+
+ async s3SyncDownload(): Promise {
+ return await invoke("s3_sync_download");
+ },
+
+ async s3SyncSaveSettings(
+ settings: S3SyncSettings,
+ passwordTouched: boolean,
+ ): Promise<{ success: boolean }> {
+ return await invoke("s3_sync_save_settings", {
+ settings,
+ passwordTouched,
+ });
+ },
+
+ async s3SyncFetchRemoteInfo(): Promise {
+ return await invoke("s3_sync_fetch_remote_info");
+ },
+
async syncCurrentProvidersLive(): Promise {
const result = (await invoke("sync_current_providers_live")) as {
success?: boolean;
diff --git a/src/types.ts b/src/types.ts
index 6d75b402d..71b547653 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -289,6 +289,20 @@ export interface WebDavSyncSettings {
status?: WebDavSyncStatus;
}
+// S3 同步配置
+export interface S3SyncSettings {
+ enabled?: boolean;
+ autoSync?: boolean;
+ region?: string;
+ bucket?: string;
+ accessKeyId?: string;
+ secretAccessKey?: string;
+ endpoint?: string;
+ remoteRoot?: string;
+ profile?: string;
+ status?: WebDavSyncStatus;
+}
+
export type RemoteSnapshotLayout = "current" | "legacy";
// 远端快照信息(下载前预览)
@@ -382,6 +396,9 @@ export interface Settings {
// ===== WebDAV v2 同步设置 =====
webdavSync?: WebDavSyncSettings;
+ // ===== S3 同步设置 =====
+ s3Sync?: S3SyncSettings;
+
// ===== 备份策略设置 =====
// Auto-backup interval in hours (0=disabled, default 24)
backupIntervalHours?: number;
diff --git a/tests/integration/App.test.tsx b/tests/integration/App.test.tsx
index b9dde3cf7..34236dd5a 100644
--- a/tests/integration/App.test.tsx
+++ b/tests/integration/App.test.tsx
@@ -244,6 +244,22 @@ describe("App integration with MSW", () => {
await waitFor(() => {
expect(toastErrorMock).toHaveBeenCalled();
});
+
+ toastErrorMock.mockReset();
+ expect(() => {
+ emitTauriEvent("s3-sync-status-updated", null);
+ }).not.toThrow();
+ expect(toastErrorMock).not.toHaveBeenCalled();
+
+ emitTauriEvent("s3-sync-status-updated", {
+ source: "auto",
+ status: "error",
+ error: "s3 timeout",
+ });
+
+ await waitFor(() => {
+ expect(toastErrorMock).toHaveBeenCalled();
+ });
});
it("duplicates openclaw providers with a generated key that avoids live-only ids", async () => {