diff --git a/CONTEXT.md b/CONTEXT.md index f3191090..8ee2b575 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -27,7 +27,9 @@ - **激活(Activation)**:将某条本地持有的剪贴板内容推进为活动剪贴板状态的动作。 - **收敛(Convergence)**:成员交换活动剪贴板状态后,依据既定顺序规则得到一致状态的过程。 - **重同步(Resync)**:成员重新在线后,将当前活动剪贴板状态重新发送给该成员以促进收敛的动作。它只处理当前活动状态,不补送历史条目。 -- **离线投递恢复(Offline Delivery Recovery)**:自动同步开启时,本机内容先立即投递;既有设备暂时不可达时,每台设备只保留最新一条本机内容作为自动补送目标。新的本机复制会替代该设备更早的待送内容;待其恢复在线或应用重启后重新发现在线时,只自动补送这一条。它不向新设备补送旧内容,不处理远端来源,不重复发送已送达或已被替代的内容;自动同步关闭或内容无法恢复时停止自动补送。 +- **同步总开关(Global Sync)**:用户对同步的总许可。关闭后,不接收内容、不发送内容或文件,也不执行自动补送、手动重发和恢复广播。 +- **自动同步(Automatic Sync)**:同步总开关开启时,对本机新内容自动发送和离线自动补送的选择。关闭后,用户仍可手动重发允许发送的内容或文件。 +- **离线投递恢复(Offline Delivery Recovery)**:自动同步开启时,本机内容先立即投递;既有设备暂时不可达时,每台设备只保留最新一条本机内容作为自动补送目标。新的本机复制会替代该设备更早的待送内容;待其恢复在线或应用重启后重新发现在线时,只自动补送这一条。它不向新设备补送旧内容,不处理远端来源,不重复发送已送达或已被替代的内容;自动同步关闭、同步总开关关闭或内容无法恢复时停止自动补送。 - **已被替代(Superseded)**:同一设备有更新的本机内容后,更早的暂时未送达内容进入的最终状态。它不会再自动补送,也不表示发送失败。 - **历史(History)**:已持久化的剪贴板条目及其检索、资源和投递状态集合。 - **普通远端接收(Interactive Receive)**:收到远端内容后保存到历史,并写入本机剪贴板的完整接收方式。 diff --git a/Cargo.lock b/Cargo.lock index af9a9a3d..31a7b77d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4522,7 +4522,7 @@ dependencies = [ [[package]] name = "openmls-validation" -version = "1.0.0-rc.7" +version = "1.0.0-rc.8" dependencies = [ "openmls", "openmls_basic_credential", @@ -7076,7 +7076,7 @@ checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "uc-application" -version = "1.0.0-rc.7" +version = "1.0.0-rc.8" dependencies = [ "anyhow", "async-trait", @@ -7114,7 +7114,7 @@ dependencies = [ [[package]] name = "uc-content-hash" -version = "1.0.0-rc.7" +version = "1.0.0-rc.8" dependencies = [ "blake3", "hex", @@ -7122,7 +7122,7 @@ dependencies = [ [[package]] name = "uc-core" -version = "1.0.0-rc.7" +version = "1.0.0-rc.8" dependencies = [ "anyhow", "arrayvec", @@ -7152,7 +7152,7 @@ dependencies = [ [[package]] name = "uc-engine" -version = "1.0.0-rc.7" +version = "1.0.0-rc.8" dependencies = [ "anyhow", "async-trait", @@ -7182,7 +7182,7 @@ dependencies = [ [[package]] name = "uc-engine-uniffi" -version = "1.0.0-rc.7" +version = "1.0.0-rc.8" dependencies = [ "cargo_metadata", "jni 0.21.1", @@ -7204,7 +7204,7 @@ dependencies = [ [[package]] name = "uc-infra" -version = "1.0.0-rc.7" +version = "1.0.0-rc.8" dependencies = [ "anyhow", "argon2", @@ -7289,7 +7289,7 @@ dependencies = [ [[package]] name = "uc-mobile-lan" -version = "1.0.0-rc.7" +version = "1.0.0-rc.8" dependencies = [ "anyhow", "async-trait", @@ -7316,7 +7316,7 @@ dependencies = [ [[package]] name = "uc-mobile-probe-core" -version = "1.0.0-rc.7" +version = "1.0.0-rc.8" dependencies = [ "base64", "jni 0.21.1", @@ -7331,7 +7331,7 @@ dependencies = [ [[package]] name = "uc-mobile-proto" -version = "1.0.0-rc.7" +version = "1.0.0-rc.8" dependencies = [ "base64", "chrono", @@ -7345,7 +7345,7 @@ dependencies = [ [[package]] name = "uc-observability-contract" -version = "1.0.0-rc.7" +version = "1.0.0-rc.8" dependencies = [ "anyhow", "chrono", @@ -7360,7 +7360,7 @@ dependencies = [ [[package]] name = "uc-ohos-napi" -version = "1.0.0-rc.7" +version = "1.0.0-rc.8" dependencies = [ "cargo_metadata", "json5", diff --git a/Cargo.toml b/Cargo.toml index 29061f96..671f9a41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ members = [ resolver = "2" [workspace.package] -version = "1.0.0-rc.7" +version = "1.0.0-rc.8" license = "Apache-2.0" [workspace.lints.clippy] diff --git a/bindings/uc-engine-uniffi/src/runtime.rs b/bindings/uc-engine-uniffi/src/runtime.rs index aa4f27b9..1dd846f8 100644 --- a/bindings/uc-engine-uniffi/src/runtime.rs +++ b/bindings/uc-engine-uniffi/src/runtime.rs @@ -254,6 +254,7 @@ pub enum ResendEntryOutcome { errored: u64, pending: u64, }, + SynchronizationDisabled, EntryNotFound { entry_id: String, }, @@ -1818,6 +1819,9 @@ fn map_resend_outcome(result: OperationResult) -> Result { + ResendEntryOutcome::SynchronizationDisabled + } uc_engine::ResendEntryOutcome::EntryNotFound { entry_id } => { ResendEntryOutcome::EntryNotFound { entry_id } } diff --git a/crates/uc-application/src/clipboard/inbound/runtime.rs b/crates/uc-application/src/clipboard/inbound/runtime.rs index 436092d1..148ba4ca 100644 --- a/crates/uc-application/src/clipboard/inbound/runtime.rs +++ b/crates/uc-application/src/clipboard/inbound/runtime.rs @@ -13,7 +13,7 @@ use uc_core::ids::DeviceId; use uc_core::ports::security::TransferCipherPort; use uc_core::ports::{ ClipboardReceiverPort, ClockPort, ConnectionChannel, InboundClipboard, - InboundClipboardDisposition, InboundClipboardReceipt, + InboundClipboardDisposition, InboundClipboardReceipt, SettingsPort, }; use uc_core::MemberRepositoryPort; use uc_observability_contract::analytics::{ @@ -61,6 +61,7 @@ pub struct ClipboardInboundRuntimeDeps { pub receiver: Arc, pub member_repo: Arc, pub transfer_cipher: Arc, + pub settings: Arc, pub clock: Arc, pub apply: Arc, pub events: Arc, @@ -79,6 +80,7 @@ pub struct ClipboardInboundRuntime { struct InboundProcessor { receive_gate: MemberReceiveGate, + settings: Arc, transfer_cipher: Arc, clock: Arc, apply: Arc, @@ -117,6 +119,7 @@ impl ClipboardInboundRuntime { let mut receiver = deps.receiver.subscribe(); let processor = InboundProcessor { receive_gate: MemberReceiveGate::new(deps.member_repo), + settings: deps.settings, transfer_cipher: deps.transfer_cipher, clock: deps.clock, apply: deps.apply, @@ -278,6 +281,10 @@ impl InboundProcessor { ..InboundTiming::default() }; let receiver_policy_started_at = Instant::now(); + if !inbound_sync_enabled(self.settings.as_ref()).await { + receipt.finish(InboundClipboardDisposition::Rejected); + return None; + } if !self .receive_gate .is_receive_allowed(&inbound.peer_device_id) @@ -379,6 +386,19 @@ impl InboundProcessor { } } +async fn inbound_sync_enabled(settings: &dyn SettingsPort) -> bool { + match settings.load().await { + Ok(settings) => settings.sync.sync_enabled, + Err(_) => { + warn!( + error_kind = "settings_load", + "clipboard inbound: delivery rejected" + ); + false + } + } +} + fn duration_ms(duration: Duration) -> u32 { duration.as_millis().min(u32::MAX as u128) as u32 } @@ -494,7 +514,7 @@ mod tests { use uc_core::ports::security::{TransferCipherError, TransferCipherPort}; use uc_core::ports::{ ClipboardHeader, ClipboardReceiverPort, ClockPort, ConnectionChannel, InboundClipboard, - InboundClipboardDisposition, InboundClipboardReceipt, InboundClipboardResult, + InboundClipboardDisposition, InboundClipboardReceipt, InboundClipboardResult, SettingsPort, }; use uc_core::security::IdentityFingerprint; use uc_core::{ @@ -514,6 +534,23 @@ mod tests { tx: broadcast::Sender, } + struct FixedSettings { + sync_enabled: bool, + } + + #[async_trait] + impl SettingsPort for FixedSettings { + async fn load(&self) -> anyhow::Result { + let mut settings = uc_core::settings::model::Settings::default(); + settings.sync.sync_enabled = self.sync_enabled; + Ok(settings) + } + + async fn save(&self, _settings: &uc_core::settings::model::Settings) -> anyhow::Result<()> { + Ok(()) + } + } + impl FakeReceiver { fn new() -> Self { let (tx, _) = broadcast::channel(16); @@ -831,6 +868,7 @@ mod tests { receiver, member_repo: Arc::new(AllowAllMembers), transfer_cipher: Arc::new(EchoCipher), + settings: Arc::new(FixedSettings { sync_enabled: true }), clock: Arc::new(FixedClock), apply, events, @@ -848,6 +886,7 @@ mod tests { receiver, member_repo, transfer_cipher, + settings: Arc::new(FixedSettings { sync_enabled: true }), clock: Arc::new(FixedClock), apply, events, @@ -1092,6 +1131,32 @@ mod tests { runtime.shutdown().await.expect("runtime shutdown"); } + #[tokio::test] + async fn global_sync_disabled_rejects_before_decrypt_or_apply() { + let receiver = Arc::new(FakeReceiver::new()); + let mut runtime_deps = deps( + Arc::clone(&receiver), + Arc::new(NeverApply), + Arc::new(RecordingEvents::default()), + ); + runtime_deps.settings = Arc::new(FixedSettings { + sync_enabled: false, + }); + runtime_deps.transfer_cipher = Arc::new(NeverCipher); + let runtime = ClipboardInboundRuntime::start(runtime_deps); + let (inbound, result) = fixture("peer-disabled", "hash-disabled"); + + receiver.publish(inbound); + + assert_eq!( + tokio::time::timeout(Duration::from_secs(1), result.wait()) + .await + .expect("receipt settled"), + Some(InboundClipboardDisposition::Rejected) + ); + runtime.shutdown().await.expect("runtime shutdown"); + } + #[tokio::test] async fn unavailable_member_preferences_reject_before_decrypt_or_apply() { for lookup in [MemberLookup::Missing, MemberLookup::Failed] { diff --git a/crates/uc-application/src/clipboard/sync/active_state/restore_broadcast_worker.rs b/crates/uc-application/src/clipboard/sync/active_state/restore_broadcast_worker.rs index 3d9f863b..13f47c8b 100644 --- a/crates/uc-application/src/clipboard/sync/active_state/restore_broadcast_worker.rs +++ b/crates/uc-application/src/clipboard/sync/active_state/restore_broadcast_worker.rs @@ -115,7 +115,7 @@ impl RestoreBroadcastWorker { // Re-read the toggle at emit time so a setting change between the // restore and this debounced emit is respected. let sync_on_restore = match self.settings.load().await { - Ok(settings) => settings.sync.sync_on_restore, + Ok(settings) => settings.sync.sync_enabled && settings.sync.sync_on_restore, Err(err) => { // Fail closed: if we can't confirm the user opted in, don't // announce. A restore that should have broadcast is recovered diff --git a/crates/uc-application/src/clipboard/sync/outbound_plan.rs b/crates/uc-application/src/clipboard/sync/outbound_plan.rs index 2a41fe83..509817d2 100644 --- a/crates/uc-application/src/clipboard/sync/outbound_plan.rs +++ b/crates/uc-application/src/clipboard/sync/outbound_plan.rs @@ -82,6 +82,13 @@ impl OutboundSyncPlanner { } }; + if !settings.sync.sync_enabled { + return OutboundSyncPlan { + clipboard: None, + files: vec![], + }; + } + // File sync is only applicable for outbound, user-initiated origins // (LocalCapture + Resend). RemotePush is already guarded above; // LocalRestore writes the snapshot back to the local clipboard with no @@ -193,6 +200,18 @@ mod tests { OutboundSyncPlanner::new(Arc::new(InMemorySettings(Mutex::new(settings)))) } + fn planner_with_global_sync_disabled() -> OutboundSyncPlanner { + let mut settings = Settings::default(); + settings.sync.sync_enabled = false; + OutboundSyncPlanner::new(Arc::new(InMemorySettings(Mutex::new(settings)))) + } + + fn planner_with_automatic_sync_disabled() -> OutboundSyncPlanner { + let mut settings = Settings::default(); + settings.sync.auto_sync_enabled = false; + OutboundSyncPlanner::new(Arc::new(InMemorySettings(Mutex::new(settings)))) + } + fn text_snapshot() -> SystemClipboardSnapshot { SystemClipboardSnapshot { ts_ms: 1_700_000_000_000, @@ -328,4 +347,34 @@ mod tests { plan.files.len() ); } + + #[tokio::test] + async fn resend_origin_is_rejected_when_global_sync_is_disabled() { + let plan = planner_with_global_sync_disabled() + .plan( + text_snapshot(), + ClipboardChangeOrigin::Resend, + vec![candidate("blocked.bin", 1)], + 1, + ) + .await; + + assert!(plan.clipboard.is_none()); + assert!(plan.files.is_empty()); + } + + #[tokio::test] + async fn resend_origin_keeps_file_sync_when_automatic_sync_is_disabled() { + let plan = planner_with_automatic_sync_disabled() + .plan( + text_snapshot(), + ClipboardChangeOrigin::Resend, + vec![candidate("manual.bin", 1)], + 1, + ) + .await; + + assert_eq!(plan.files.len(), 1); + assert!(plan.clipboard.is_some(), "manual resend remains enabled"); + } } diff --git a/crates/uc-application/src/clipboard/sync/resend_entry.rs b/crates/uc-application/src/clipboard/sync/resend_entry.rs index acda285a..0b6a995b 100644 --- a/crates/uc-application/src/clipboard/sync/resend_entry.rs +++ b/crates/uc-application/src/clipboard/sync/resend_entry.rs @@ -67,6 +67,9 @@ pub struct ResendReport { /// 错误集合,不向上漏出底层仓储 / dispatch 错误。 #[derive(Debug, Error)] pub enum ResendEntryError { + #[error("synchronization is disabled")] + SynchronizationDisabled, + /// `entry_repo.get_entry` 返回 `None`。可能是 entry 已被用户删除, /// 也可能是 UI 拿到一份过期视图后才点击重发。 #[error("entry not found: {0}")] diff --git a/crates/uc-application/src/clipboard/sync/sync_runtime.rs b/crates/uc-application/src/clipboard/sync/sync_runtime.rs index f7ef6d57..7ef01aaa 100644 --- a/crates/uc-application/src/clipboard/sync/sync_runtime.rs +++ b/crates/uc-application/src/clipboard/sync/sync_runtime.rs @@ -87,9 +87,9 @@ impl ClipboardSyncRuntime { target_filter: Option>, ) -> Result { let _gate = self.delivery_gate.lock().await; - if !auto_sync_enabled(self.settings.as_ref()).await { + if !automatic_sync_enabled(self.settings.as_ref()).await { return Ok(ClipboardOutboundOutcome::Skipped { - reason: "auto_sync_disabled".to_string(), + reason: "automatic_sync_disabled".to_string(), }); } let entry_id = EntryId::from(input.entry_id.as_str()); @@ -113,12 +113,15 @@ impl ClipboardSyncRuntime { Ok(outcome) } - /// Manual resend remains explicit and is intentionally independent of the - /// automatic-sync toggle. + /// Manual resend remains independent of the automatic-sync toggle, but it + /// still requires the global synchronization permission. pub async fn resend_entry( &self, command: ResendEntryCommand, ) -> Result { + if !sync_enabled(self.settings.as_ref()).await { + return Err(ResendEntryError::SynchronizationDisabled); + } self.outbound.resend_entry(command).await } @@ -135,9 +138,22 @@ impl ClipboardSyncRuntime { } } -async fn auto_sync_enabled(settings: &dyn SettingsPort) -> bool { +async fn sync_enabled(settings: &dyn SettingsPort) -> bool { + match settings.load().await { + Ok(settings) => settings.sync.sync_enabled, + Err(_) => { + warn!( + error_kind = "settings_load", + "clipboard sync: delivery skipped" + ); + false + } + } +} + +async fn automatic_sync_enabled(settings: &dyn SettingsPort) -> bool { match settings.load().await { - Ok(settings) => settings.sync.auto_sync, + Ok(settings) => settings.sync.sync_enabled && settings.sync.auto_sync_enabled, Err(_) => { warn!( error_kind = "settings_load", @@ -271,7 +287,7 @@ async fn recover_currently_online(deps: &OfflineDeliveryRecoveryDeps) { } async fn recover_for_target(deps: &OfflineDeliveryRecoveryDeps, target: DeviceId) { - if !auto_sync_enabled(deps.settings.as_ref()).await { + if !automatic_sync_enabled(deps.settings.as_ref()).await { return; } @@ -336,7 +352,7 @@ async fn recover_for_target(deps: &OfflineDeliveryRecoveryDeps, target: DeviceId if !matches!(record.status, EntryDeliveryStatus::Unreachable) { return; } - if !auto_sync_enabled(deps.settings.as_ref()).await { + if !automatic_sync_enabled(deps.settings.as_ref()).await { return; } match deps @@ -475,14 +491,16 @@ mod tests { use uc_core::settings::model::Settings; struct FixedSettings { - auto_sync: bool, + sync_enabled: bool, + auto_sync_enabled: bool, } #[async_trait] impl SettingsPort for FixedSettings { async fn load(&self) -> anyhow::Result { let mut settings = Settings::default(); - settings.sync.auto_sync = self.auto_sync; + settings.sync.sync_enabled = self.sync_enabled; + settings.sync.auto_sync_enabled = self.auto_sync_enabled; Ok(settings) } @@ -662,7 +680,7 @@ mod tests { } fn recovery_deps( - auto_sync: bool, + auto_sync_enabled: bool, entries: Vec, sources: HashMap, deliveries: Arc, @@ -672,7 +690,10 @@ mod tests { OfflineDeliveryRecoveryDeps { presence: Arc::new(IdlePresence { tx }), known_peers: Arc::new(NoPeers), - settings: Arc::new(FixedSettings { auto_sync }), + settings: Arc::new(FixedSettings { + sync_enabled: true, + auto_sync_enabled, + }), entries: Arc::new(Entries(entries)), events: Arc::new(Sources { sources }), deliveries, @@ -899,6 +920,43 @@ mod tests { assert!(delivery.commands.lock().unwrap().is_empty()); } + #[tokio::test] + async fn disabled_global_sync_never_dispatches_a_saved_offline_delivery() { + let pending_entry = entry("offline-entry", "local-event"); + let target = DeviceId::new("recovered"); + let deliveries = Arc::new(Deliveries { + records: Mutex::new(HashMap::from([( + pending_entry.entry_id.clone(), + vec![EntryDeliveryRecord { + entry_id: pending_entry.entry_id.clone(), + target_device_id: target.clone(), + status: EntryDeliveryStatus::Unreachable, + reason_detail: None, + updated_at_ms: 1, + }], + )])), + }); + let delivery = Arc::new(RecordingDispatch { + commands: Mutex::new(Vec::new()), + result: DispatchResult::Delivered, + }); + let mut deps = recovery_deps( + true, + vec![pending_entry.clone()], + HashMap::from([(pending_entry.event_id.clone(), DeviceId::new("local"))]), + deliveries, + Arc::clone(&delivery), + ); + deps.settings = Arc::new(FixedSettings { + sync_enabled: false, + auto_sync_enabled: true, + }); + + recover_for_target(&deps, target).await; + + assert!(delivery.commands.lock().unwrap().is_empty()); + } + #[tokio::test] async fn payload_lost_stops_future_automatic_recovery_for_that_entry() { let pending_entry = entry("offline-entry", "local-event"); diff --git a/crates/uc-application/src/settings/models.rs b/crates/uc-application/src/settings/models.rs index 2522bfc9..2bab2a43 100644 --- a/crates/uc-application/src/settings/models.rs +++ b/crates/uc-application/src/settings/models.rs @@ -110,7 +110,8 @@ pub struct GeneralSettingsView { #[derive(Debug, Clone)] pub struct SyncSettingsView { - pub auto_sync: bool, + pub sync_enabled: bool, + pub auto_sync_enabled: bool, pub sync_frequency: SyncFrequencyView, pub content_types: ContentTypesView, pub sync_on_restore: bool, @@ -240,7 +241,8 @@ pub struct GeneralSettingsPatch { #[derive(Debug, Clone, Default)] pub struct SyncSettingsPatch { - pub auto_sync: Option, + pub sync_enabled: Option, + pub auto_sync_enabled: Option, pub sync_frequency: Option, pub content_types: Option, pub sync_on_restore: Option, @@ -579,7 +581,8 @@ impl From for SettingsView { debug_mode: value.general.debug_mode, }, sync: SyncSettingsView { - auto_sync: value.sync.auto_sync, + sync_enabled: value.sync.sync_enabled, + auto_sync_enabled: value.sync.auto_sync_enabled, sync_frequency: value.sync.sync_frequency.into(), content_types: value.sync.content_types.into(), sync_on_restore: value.sync.sync_on_restore, @@ -693,8 +696,11 @@ pub(crate) fn apply_settings_patch( } if let Some(sync) = patch.sync { - if let Some(v) = sync.auto_sync { - existing.sync.auto_sync = v; + if let Some(v) = sync.sync_enabled { + existing.sync.sync_enabled = v; + } + if let Some(v) = sync.auto_sync_enabled { + existing.sync.auto_sync_enabled = v; } if let Some(v) = sync.sync_frequency { existing.sync.sync_frequency = v.into(); @@ -1142,6 +1148,35 @@ mod file_sync_auto_save_dir_apply_patch_tests { } } + #[test] + fn sync_patch_preserves_global_and_automatic_choices_independently() { + let global_disabled = apply_settings_patch( + Settings::default(), + SettingsPatch { + sync: Some(SyncSettingsPatch { + sync_enabled: Some(false), + ..Default::default() + }), + ..Default::default() + }, + ); + assert!(!global_disabled.sync.sync_enabled); + assert!(global_disabled.sync.auto_sync_enabled); + + let automatic_disabled = apply_settings_patch( + Settings::default(), + SettingsPatch { + sync: Some(SyncSettingsPatch { + auto_sync_enabled: Some(false), + ..Default::default() + }), + ..Default::default() + }, + ); + assert!(automatic_disabled.sync.sync_enabled); + assert!(!automatic_disabled.sync.auto_sync_enabled); + } + /// Absent field (patch = None) leaves an existing directory untouched. #[test] fn absent_auto_save_dir_keeps_existing() { diff --git a/crates/uc-core/src/settings/defaults.rs b/crates/uc-core/src/settings/defaults.rs index 0ce5e6fa..bfb37e67 100644 --- a/crates/uc-core/src/settings/defaults.rs +++ b/crates/uc-core/src/settings/defaults.rs @@ -111,8 +111,8 @@ impl Default for ContentTypes { impl Default for SyncSettings { /// Creates a `SyncSettings` populated with sensible defaults. /// - /// The defaults enable automatic syncing, use realtime sync frequency, and include the - /// default content types. + /// The defaults enable global and automatic syncing, use realtime sync frequency, and + /// include the default content types. /// /// # Examples /// @@ -120,13 +120,15 @@ impl Default for SyncSettings { /// use uc_core::settings::model::{SyncSettings, SyncFrequency}; /// /// let s = SyncSettings::default(); - /// assert!(s.auto_sync); + /// assert!(s.sync_enabled); + /// assert!(s.auto_sync_enabled); /// assert_eq!(s.sync_frequency, SyncFrequency::Realtime); /// assert!(!s.sync_on_restore); /// ``` fn default() -> Self { Self { - auto_sync: true, + sync_enabled: true, + auto_sync_enabled: true, sync_frequency: SyncFrequency::Realtime, content_types: ContentTypes::default(), // Opt-in: a restore announces active content to peers only when @@ -343,7 +345,7 @@ mod tests { use crate::settings::model::{ ContentTypes, FileSyncSettings, GeneralSettings, NetworkSettings, QuickPanelDoubleTapModifier, QuickPanelSettings, Settings, StartupMode, SyncFrequency, - Theme, CURRENT_SCHEMA_VERSION, + SyncSettings, Theme, CURRENT_SCHEMA_VERSION, }; /// Pitfall 2 防御:默认值必须为 true(允许 fallback),保护老用户 @@ -620,16 +622,27 @@ mod tests { assert!(s.general.telemetry_enabled); } - /// `sync` 段缺 `content_types` 与 `auto_sync`,均回退默认。 + /// `sync` 段缺开关与 `content_types` 时均回退默认。 #[test] fn sync_missing_fields_fall_back_to_default() { let json = r#"{ "sync": { "sync_frequency": "interval" } }"#; let s: Settings = serde_json::from_str(json).expect("partial sync must parse"); assert_eq!(s.sync.sync_frequency, SyncFrequency::Interval); - assert!(s.sync.auto_sync); + assert!(s.sync.sync_enabled); + assert!(s.sync.auto_sync_enabled); assert_eq!(s.sync.content_types, ContentTypes::default()); } + #[test] + fn sync_settings_persist_independent_global_and_automatic_choices() { + let persisted = + serde_json::to_value(SyncSettings::default()).expect("serialize sync settings"); + + assert_eq!(persisted["sync_enabled"], true); + assert_eq!(persisted["auto_sync_enabled"], true); + assert!(persisted.get("auto_sync").is_none()); + } + /// `security` 段缺所有字段时回退默认,且未来加新字段不会再 break 启动。 #[test] fn security_empty_object_falls_back_to_default() { @@ -664,33 +677,6 @@ mod tests { assert!(s.mobile_sync.lan_port.is_none()); } - /// 综合回归:模拟 v0.2 时代的 settings.json(只有 general/sync, - /// 完全没有 file_sync / network / mobile_sync 等后续新增段), - /// 必须能直接反序列化为完整 Settings。 - #[test] - fn legacy_v02_settings_json_loads_with_all_defaults() { - let json = r#"{ - "schema_version": 1, - "general": { "auto_start": true, "theme": "dark" }, - "sync": { "auto_sync": false, "sync_frequency": "interval" } - }"#; - let s: Settings = serde_json::from_str(json).expect("legacy settings must parse"); - - assert_eq!(s.schema_version, 1); - assert!(s.general.auto_start); - assert_eq!(s.general.theme, Theme::Dark); - assert!(s.general.telemetry_enabled); - assert!(!s.sync.auto_sync); - assert_eq!(s.sync.content_types, ContentTypes::default()); - - // 后续新增段全部走 Default - assert_eq!(s.file_sync, FileSyncSettings::default()); - assert!(s.network.allow_relay_fallback); - assert!(!s.network.allow_overlay_network_addrs); - assert!(s.network.custom_relay_urls.is_empty()); - assert!(!s.mobile_sync.enabled); - } - /// 显式字段值不被 `#[serde(default)]` 误覆盖。 #[test] fn explicit_file_sync_values_are_preserved() { diff --git a/crates/uc-core/src/settings/model.rs b/crates/uc-core/src/settings/model.rs index d36eaf5e..249bd69c 100644 --- a/crates/uc-core/src/settings/model.rs +++ b/crates/uc-core/src/settings/model.rs @@ -171,7 +171,8 @@ pub struct ContentTypes { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default)] pub struct SyncSettings { - pub auto_sync: bool, + pub sync_enabled: bool, + pub auto_sync_enabled: bool, pub sync_frequency: SyncFrequency, pub content_types: ContentTypes, /// Whether restoring a historical entry makes its content the active diff --git a/crates/uc-engine/src/assembly/clipboard_runtime.rs b/crates/uc-engine/src/assembly/clipboard_runtime.rs index a6085024..0e55eb34 100644 --- a/crates/uc-engine/src/assembly/clipboard_runtime.rs +++ b/crates/uc-engine/src/assembly/clipboard_runtime.rs @@ -151,6 +151,7 @@ pub(crate) fn build_clipboard_runtime( receiver: sync_engine.clipboard_receiver(), member_repo: deps.device.member_repo.clone(), transfer_cipher: deps.security.transfer_cipher.clone(), + settings: deps.settings.clone(), clock: deps.system.clock.clone(), apply: apply_inbound.clone() as Arc, events: Arc::new(EngineClipboardInboundEvents { events }), diff --git a/crates/uc-engine/src/contract/operation.rs b/crates/uc-engine/src/contract/operation.rs index 2e6a686d..d0160ae0 100644 --- a/crates/uc-engine/src/contract/operation.rs +++ b/crates/uc-engine/src/contract/operation.rs @@ -760,6 +760,7 @@ pub struct ResendReportSummary { #[serde(rename_all = "snake_case", tag = "kind")] pub enum ResendEntryOutcome { Completed(ResendReportSummary), + SynchronizationDisabled, EntryNotFound { entry_id: String, }, diff --git a/crates/uc-engine/src/contract/settings.rs b/crates/uc-engine/src/contract/settings.rs index e7d4fc46..f9a7a355 100644 --- a/crates/uc-engine/src/contract/settings.rs +++ b/crates/uc-engine/src/contract/settings.rs @@ -171,7 +171,8 @@ pub struct GeneralSettingsPatch { #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct SyncSettingsSummary { - pub auto_sync: bool, + pub sync_enabled: bool, + pub auto_sync_enabled: bool, pub sync_frequency: SyncFrequencySummary, pub content_types: SettingsContentTypes, pub sync_on_restore: bool, @@ -179,7 +180,8 @@ pub struct SyncSettingsSummary { #[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct SyncSettingsPatch { - pub auto_sync: Option, + pub sync_enabled: Option, + pub auto_sync_enabled: Option, pub sync_frequency: Option, pub content_types: Option, pub sync_on_restore: Option, diff --git a/crates/uc-engine/src/operations/history/resend.rs b/crates/uc-engine/src/operations/history/resend.rs index ee759b35..95bed9d1 100644 --- a/crates/uc-engine/src/operations/history/resend.rs +++ b/crates/uc-engine/src/operations/history/resend.rs @@ -43,6 +43,9 @@ fn map_resend_result( errored: report.errored, pending: report.pending, }), + Err(ResendEntryError::SynchronizationDisabled) => { + ResendEntryOutcome::SynchronizationDisabled + } Err(ResendEntryError::EntryNotFound(entry_id)) => ResendEntryOutcome::EntryNotFound { entry_id: entry_id.as_str().to_string(), }, diff --git a/crates/uc-engine/src/operations/settings/settings.rs b/crates/uc-engine/src/operations/settings/settings.rs index 6026bc33..fd579f10 100644 --- a/crates/uc-engine/src/operations/settings/settings.rs +++ b/crates/uc-engine/src/operations/settings/settings.rs @@ -209,7 +209,8 @@ fn map_settings(settings: app::SettingsView) -> SettingsSummary { debug_mode: settings.general.debug_mode, }, sync: SyncSettingsSummary { - auto_sync: settings.sync.auto_sync, + sync_enabled: settings.sync.sync_enabled, + auto_sync_enabled: settings.sync.auto_sync_enabled, sync_frequency: map_sync_frequency(settings.sync.sync_frequency), content_types: map_content_types(settings.sync.content_types), sync_on_restore: settings.sync.sync_on_restore, @@ -293,7 +294,8 @@ fn map_patch(patch: SettingsPatch) -> Result { debug_mode: value.debug_mode, }), sync: patch.sync.map(|value| app::SyncSettingsPatch { - auto_sync: value.auto_sync, + sync_enabled: value.sync_enabled, + auto_sync_enabled: value.auto_sync_enabled, sync_frequency: value.sync_frequency.map(unmap_sync_frequency), content_types: value.content_types.map(unmap_content_types_patch), sync_on_restore: value.sync_on_restore, diff --git a/crates/uc-engine/src/testing/host_adapter_contract.rs b/crates/uc-engine/src/testing/host_adapter_contract.rs index 1e470a1b..daa0d7fe 100644 --- a/crates/uc-engine/src/testing/host_adapter_contract.rs +++ b/crates/uc-engine/src/testing/host_adapter_contract.rs @@ -146,10 +146,32 @@ async fn engine_clipboard_inbound_preserves_success_duplicate_and_shutdown_behav let sponsor_root = tempfile::tempdir().unwrap(); let joiner_root = tempfile::tempdir().unwrap(); let config = EngineConfig::new("1.2.3").with_rendezvous_base_url(rendezvous.uri()); - let (sponsor, mut sponsor_events) = - Engine::start(config.clone(), empty_engine_host(sponsor_root.path())) - .await - .unwrap(); + let file_bytes = b"manual file resend reaches the second engine".to_vec(); + let file_display_name = "manual-resend.txt"; + let sponsor_file_state = Arc::new(RecordingHostFilesState::default()); + let sponsor_host = HostCapabilities::new( + HostDirectories::new( + sponsor_root.path().join("private"), + sponsor_root.path().join("cache"), + sponsor_root.path().join("temporary"), + sponsor_root.path().join("logs"), + ), + Box::new(MemoryHostSecureStorage::default()), + Box::new(StaticHostClipboard { + snapshot: HostClipboardSnapshot { + observed_at_ms: 0, + representations: Vec::new(), + }, + }), + Box::new(ReadableHostFiles { + handle: "manual-resend-file".into(), + display_name: file_display_name.into(), + mime_type: Some("text/plain".into()), + bytes: file_bytes.clone(), + state: sponsor_file_state, + }), + ); + let (sponsor, mut sponsor_events) = Engine::start(config.clone(), sponsor_host).await.unwrap(); let (joiner, mut joiner_events) = Engine::start(config, empty_engine_host(joiner_root.path())) .await .unwrap(); @@ -199,6 +221,15 @@ async fn engine_clipboard_inbound_preserves_success_duplicate_and_shutdown_behav EngineEvent::WorkspaceConvergenceChanged(_) )); + assert!(matches!( + sponsor + .execute(crate::Operation::QuerySettings) + .await + .unwrap(), + crate::OperationResult::Settings(settings) + if settings.sync.sync_enabled && settings.sync.auto_sync_enabled + )); + let text = "engine inbound behavior baseline"; let first_send = sponsor .execute(crate::Operation::SendText(crate::SendTextInput { @@ -244,7 +275,7 @@ async fn engine_clipboard_inbound_preserves_success_duplicate_and_shutdown_behav let resend = sponsor .execute(crate::Operation::ResendEntry(crate::ResendEntryInput { entry_id: first_entry_id, - target_devices: vec![joiner_device_id], + target_devices: vec![joiner_device_id.clone()], })) .await .unwrap(); @@ -274,6 +305,132 @@ async fn engine_clipboard_inbound_preserves_success_duplicate_and_shutdown_behav if entries.len() == 1 && entries[0].preview.as_deref() == Some(text) )); + sponsor + .execute(crate::Operation::UpdateMemberSyncPreferences( + crate::UpdateMemberSyncPreferencesInput { + device_id: joiner_device_id.clone(), + patch: crate::MemberSyncPreferencesPatch { + send_enabled: Some(false), + ..Default::default() + }, + }, + )) + .await + .unwrap(); + let file_entry_id = match sponsor + .execute(crate::Operation::SendFiles(crate::SendFilesInput { + files: vec![HostFileHandle::new("manual-resend-file")], + target_devices: vec![joiner_device_id.clone()], + })) + .await + .unwrap() + { + crate::OperationResult::EntrySent(report) => { + assert_eq!(report.total_accepted, 0); + report.entry_id + } + other => panic!("expected locally saved file entry, got {other:?}"), + }; + sponsor + .execute(crate::Operation::UpdateMemberSyncPreferences( + crate::UpdateMemberSyncPreferencesInput { + device_id: joiner_device_id.clone(), + patch: crate::MemberSyncPreferencesPatch { + send_enabled: Some(true), + ..Default::default() + }, + }, + )) + .await + .unwrap(); + let automatic_sync_disabled = sponsor + .execute(crate::Operation::UpdateSettings(Box::new( + crate::SettingsPatch { + sync: Some(crate::SyncSettingsPatch { + auto_sync_enabled: Some(false), + ..Default::default() + }), + ..Default::default() + }, + ))) + .await + .unwrap(); + assert!(matches!( + automatic_sync_disabled, + crate::OperationResult::SettingsUpdated( + crate::SettingsUpdateOutcome::Updated(settings) + ) if settings.sync.sync_enabled && !settings.sync.auto_sync_enabled + )); + let file_resend = sponsor + .execute(crate::Operation::ResendEntry(crate::ResendEntryInput { + entry_id: file_entry_id.clone(), + target_devices: vec![joiner_device_id], + })) + .await + .unwrap(); + assert!(matches!( + file_resend, + crate::OperationResult::EntryResent(crate::ResendEntryOutcome::Completed(report)) + if report.accepted == 1 && report.duplicate == 0 && report.errored == 0 + )); + let received_file_entry_id = tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + let result = joiner + .execute(crate::Operation::QueryHistory(crate::QueryHistoryInput { + cursor: None, + limit: 10, + query: None, + })) + .await + .unwrap(); + let crate::OperationResult::HistoryPage { entries, .. } = result else { + panic!("expected joiner history page"); + }; + if let Some(entry) = entries + .into_iter() + .find(|entry| entry.preview.as_deref() == Some(file_display_name)) + { + break entry.entry_id; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + }) + .await + .expect("joiner must retain the manually resent file"); + let received_file = joiner + .execute(crate::Operation::ReadEntryFile(crate::HistoryEntryInput { + entry_id: received_file_entry_id, + })) + .await + .unwrap(); + assert!(matches!( + received_file, + crate::OperationResult::EntryFileRead(resource) if resource.bytes == file_bytes + )); + + sponsor + .execute(crate::Operation::UpdateSettings(Box::new( + crate::SettingsPatch { + sync: Some(crate::SyncSettingsPatch { + sync_enabled: Some(false), + ..Default::default() + }), + ..Default::default() + }, + ))) + .await + .unwrap(); + assert_eq!( + sponsor + .execute(crate::Operation::ResendEntry(crate::ResendEntryInput { + entry_id: file_entry_id, + target_devices: Vec::new(), + })) + .await + .unwrap(), + crate::OperationResult::EntryResent(crate::ResendEntryOutcome::SynchronizationDisabled) + ); + sponsor .shutdown(std::time::Duration::from_secs(15)) .await diff --git a/crates/uc-engine/tests/public_contract.rs b/crates/uc-engine/tests/public_contract.rs index afe5b878..0830317f 100644 --- a/crates/uc-engine/tests/public_contract.rs +++ b/crates/uc-engine/tests/public_contract.rs @@ -1076,6 +1076,7 @@ fn resend_contract_preserves_report_and_structured_business_outcomes() { errored: 4, pending: 5, }), + uc_engine::ResendEntryOutcome::SynchronizationDisabled, uc_engine::ResendEntryOutcome::EntryNotFound { entry_id: "entry-1".into(), }, @@ -1092,7 +1093,7 @@ fn resend_contract_preserves_report_and_structured_business_outcomes() { }, uc_engine::ResendEntryOutcome::NoEligibleTargets, ]; - assert_eq!(outcomes.len(), 6); + assert_eq!(outcomes.len(), 7); assert_eq!( uc_engine::OperationResult::EntryResent(outcomes[0].clone()), uc_engine::OperationResult::EntryResent(uc_engine::ResendEntryOutcome::Completed( diff --git a/docs/architecture/architecture-bible.md b/docs/architecture/architecture-bible.md index ae834125..2f63b952 100644 --- a/docs/architecture/architecture-bible.md +++ b/docs/architecture/architecture-bible.md @@ -378,15 +378,16 @@ LAN HTTP 位于 `compatibility/`,使用独立的 `uc-mobile-v*` 版本和发 ### 离线投递恢复 -`ClipboardSyncRuntime` 是本机剪贴板投递的唯一负责人:本机复制、设备上线和用户手动重发都经由它进入既有发送或接收流程。它内部统一使用“发送已有本机内容”的能力;手动重发只选择用户允许的设备,自动恢复只选择这台刚上线设备的待送内容。自动同步开启时,本机复制立即发送;某个既有设备暂时不可达时,现有投递记录保存该事实。对同一设备,新的本机复制会将更早的 `Unreachable` 记录标记为 `Superseded`,因此最多只有最新内容保留自动补送资格。该设备重新上线或应用重启后重新发现其在线时,运行期只发送这一条,发送结果继续由原有投递记录覆盖。 +`ClipboardSyncRuntime` 是本机剪贴板投递的唯一负责人:本机复制、设备上线和用户手动重发都经由它进入既有发送或接收流程。同步总开关是所有出站、入站、文件投递、恢复和历史恢复广播的总许可;关闭时不做任何同步。总开关开启后,自动同步只控制本机复制的自动发送和离线自动补送;文件同步保持独立,手动重发只选择用户允许的设备且不受自动同步开关影响。自动恢复只选择这台刚上线设备的待送内容。某个既有设备暂时不可达时,现有投递记录保存该事实。对同一设备,新的本机复制会将更早的 `Unreachable` 记录标记为 `Superseded`,因此最多只有最新内容保留自动补送资格。该设备重新上线或应用重启后重新发现其在线时,运行期只发送这一条,发送结果继续由原有投递记录覆盖。 -运行期不保存内存队列,也不为新设备生成历史候选。已接受、重复或被新内容替代的内容、远端来源的内容、明确失败或无法重新取得的内容都不会自动补发;内容无法取得时记录为明确失败,停止自动恢复。自动同步关闭时,本机复制不产生发送尝试,既有离线记录也不会自动补发。一次发送仍不做无限循环重试,用户手动重发保留为独立动作。 +运行期不保存内存队列,也不为新设备生成历史候选。已接受、重复或被新内容替代的内容、远端来源的内容、明确失败或无法重新取得的内容都不会自动补发;内容无法取得时记录为明确失败,停止自动恢复。自动同步关闭时,本机复制不产生发送尝试,既有离线记录也不会自动补发;总同步关闭时,手动重发和入站内容同样被拒绝。一次发送仍不做无限循环重试,用户手动重发保留为独立动作。 ### 远端内容接收 ```text 收到加密帧 -> 校验对端身份和消息边界 + -> 检查同步总开关 -> 检查该成员的接收总开关 -> 应用层解密并解析内容 -> 检查内容类型接收偏好 @@ -743,7 +744,11 @@ node scripts/release/verify-release-bundle.mjs <产物目录> | 日期 | 修改范围 | 架构结论 | | --- | --- | --- | +| 2026-08-11 | 同步设置与主线更新整合 | 删除已经由文件发送列表承担的重复检查,保留手动重发的既有规则;无架构变化。 | | 2026-08-11 | 应用层未使用代码清理 | 删除重复导出、未接线的旧成员名单操作、无读取的文件计划字段、未使用错误分支,以及已被收敛运行期替代的旧入口;保留现有对外入口和实际工作空间收敛流程。无架构变化。 | +| 2026-08-11 | 同步设置领域词表 | 补充同步总开关、自动同步和离线投递恢复的稳定用户规则;与既有同步流程一致,无新增架构变化。 | +| 2026-08-11 | Engine v1.0.0-rc.8 发布准备 | 发布已验证的同步总开关与自动同步拆分;无新增架构变化。 | +| 2026-08-11 | 同步总开关与自动同步选择拆分 | 设置中的 `sync_enabled` 是所有同步方向、文件和恢复的总许可;`auto_sync_enabled` 只控制本机自动投递与离线自动补送。手动重发和历史恢复仍是用户动作:前者只受总开关和文件开关限制,后者受总开关与自身选择限制。入站运行期在解密前检查总开关,出站计划和运行期共同保证总开关不能被明确动作绕过;对外设置和手动重发结果由 Engine 统一表达。 | | 2026-08-11 | Engine 发布互通验证消费者选择 | 互通验证中的上一版 Engine 使用桌面端主分支历史中最后一个明确固定到该提交的消费者;新版继续使用当前桌面端。两套消费者在隔离目录中构建,再做双向配对和内容传输;避免当前桌面端调用新版能力时把“旧版无法编译”误判为版本互通失败。仅修正交付检查,无架构变化。 | | 2026-08-11 | Engine v1.0.0-rc.7 发布准备 | 同步工作区与 HarmonyOS 宿主版本,纳入当前主分支已合并的空间收敛和中继节点修正;无架构变化。 | | 2026-08-11 | ADR-019 与规格 019 按设备公开收敛等待状态 | `WorkspaceConvergence` 已从当前有效成员、确认、待交接和不可达事实派生当前阻塞设备名单:仅等待阶段公开,排除本机、已移除和历史实例,稳定排序并使数量等于名单长度。缺失实例到设备标识映射时不发布部分名单,而是转入 `RecoveryRequired`。查询、移除结果和变化事件共用 Engine 摘要,UniFFI、HarmonyOS 与移动测试宿主完整透传;未新增持久化字段或迁移。产品仓投影和界面验收不在本仓执行。 | diff --git a/tests/hosts/ohos/engine/oh-package.json5 b/tests/hosts/ohos/engine/oh-package.json5 index c5a020fa..ea432c48 100644 --- a/tests/hosts/ohos/engine/oh-package.json5 +++ b/tests/hosts/ohos/engine/oh-package.json5 @@ -1,6 +1,6 @@ { "name": "@uniclipboard/engine", - "version": "1.0.0-rc.7", + "version": "1.0.0-rc.8", "description": "UniClipboard unified P2P engine for HarmonyOS", "main": "Index.ets", "author": "UniClipboard", diff --git a/tests/hosts/uc-mobile-probe-core/src/lib.rs b/tests/hosts/uc-mobile-probe-core/src/lib.rs index 864bd152..90451ee6 100644 --- a/tests/hosts/uc-mobile-probe-core/src/lib.rs +++ b/tests/hosts/uc-mobile-probe-core/src/lib.rs @@ -666,7 +666,8 @@ fn operation_response(result: OperationResult) -> Value { "ok": true, "kind": "settings", "schema_version": settings.schema_version, - "auto_sync": settings.sync.auto_sync, + "sync_enabled": settings.sync.sync_enabled, + "auto_sync_enabled": settings.sync.auto_sync_enabled, "retention_enabled": settings.retention_policy.enabled, "retention_rule_count": settings.retention_policy.rules.len(), "shortcut_count": settings.keyboard_shortcuts.len(), @@ -1299,6 +1300,11 @@ fn operation_response(result: OperationResult) -> Value { "errored": report.errored, "pending": report.pending, }), + uc_engine::ResendEntryOutcome::SynchronizationDisabled => json!({ + "ok": true, + "kind": "entry_resent", + "outcome": "synchronization_disabled", + }), uc_engine::ResendEntryOutcome::EntryNotFound { .. } => json!({ "ok": true, "kind": "entry_resent", @@ -1658,6 +1664,18 @@ mod tests { assert_eq!(resend["accepted"], 1); assert_eq!(resend["pending"], 5); + let synchronization_disabled = operation_response(OperationResult::EntryResent( + uc_engine::ResendEntryOutcome::SynchronizationDisabled, + )); + assert_eq!( + synchronization_disabled, + json!({ + "ok": true, + "kind": "entry_resent", + "outcome": "synchronization_disabled", + }) + ); + let upgrade = operation_response(OperationResult::UpgradeStatus( uc_engine::UpgradeStatusSummary::Upgraded { from: Some("1.1.0".into()),