diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index 5f151a8..99eefbb 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -59,3 +59,12 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - 旧任务进入 conflict/archived 后拒绝上传或提交回执更新;未解决文件暂停发送,其他文件仍可推进。同路径删除后重建使用新的远端 file_id,原删除身份保留为 tombstone。 - 两个独立 Rust Workspace/会话通过真实本地 Sync HTTP 服务验证三种选择最终收敛及副本独立身份。新增三项测试覆盖文件已提交但解决记录未提交时重启、删除对编辑、远端删除后重建与恢复旧身份。 - Rust desktop 全目标 42 项通过,Clippy `-D warnings` 通过。测试服务使用隔离 SQLite/文件对象存储;尚不代表 PostgreSQL/MinIO 部署验收。不同文件身份占用同一路径仍返回明确的需改名错误,解决界面和 Host 会话接入继续实现中。 + + +## 增量:Host Sync 会话、轮询和设置页 + +- 登录/刷新会话按规范化服务器地址和账户分隔,令牌只保存在 Stronghold Sync scope。手动锁定也增加撤销代际,正在等待的网络操作在下一次 50ms 检查时取消;锁定后不再解析新令牌。 +- 设置页提供登录、创建/选择远端库、空库初始绑定、暂停/恢复、退出、解绑和冲突选择。绑定与覆盖决定使用界面确认;HTTP 只有显式勾选测试选项才能启用。冲突使用当前本地摘要做 CAS,旧原始快照仍保留。 +- Host 提供每 5 秒串行同步,先完成固定拉取窗口再发送本地作业;SQLite schema 6 保存暂停偏好。网络失败使用有界退避并尊重 Retry-After;认证、权限、协议等错误停止自动重试并显示。绑定变化/暂停取消旧轮次。 +- 真实本地 HTTP 服务验证 Stronghold 会话存储、强制刷新、关闭重开保险库后复用、退出后旧令牌 401。新增 UI 测试验证密码清空、测试 HTTP 显式传参、确认取消和冲突 CAS 参数。Rust 全目标 43 项与前端全量 95 文件/508 项通过,正式 TypeScript 两项目检查通过。前端测试仍输出既有 KaTeX quirks/localhost:3000 连接警告,没有失败项。 +- 尚未完成:后台轮询真实桌面实机验收、初始非空合并预览、外部文件变化捕获、附件流式持久化及全故障矩阵、数据分类和不同身份同路径解决。不能据此标记所有 S/E 验收 ID 已通过。 diff --git a/frontend/src-tauri/build.rs b/frontend/src-tauri/build.rs index 0067e8f..1589d5d 100644 --- a/frontend/src-tauri/build.rs +++ b/frontend/src-tauri/build.rs @@ -16,6 +16,16 @@ fn main() { tauri_build::try_build(tauri_build::Attributes::new().app_manifest( tauri_build::AppManifest::new().commands(&[ "host_capabilities", + "sync_login", + "sync_vaults", + "sync_create_vault", + "sync_bind", + "sync_unbind", + "sync_pause", + "sync_status", + "sync_resolve", + "sync_logout", + "sync_run", "credentials_status", "credentials_unlock", "credentials_lock", diff --git a/frontend/src-tauri/capabilities/main.json b/frontend/src-tauri/capabilities/main.json index b093a98..26f6e08 100644 --- a/frontend/src-tauri/capabilities/main.json +++ b/frontend/src-tauri/capabilities/main.json @@ -36,6 +36,16 @@ "core:window:allow-minimize", "core:window:allow-toggle-maximize", "core:window:allow-start-dragging", - "allow-editor-capabilities" + "allow-editor-capabilities", + "allow-sync-login", + "allow-sync-vaults", + "allow-sync-create-vault", + "allow-sync-bind", + "allow-sync-unbind", + "allow-sync-pause", + "allow-sync-status", + "allow-sync-resolve", + "allow-sync-logout", + "allow-sync-run" ] } diff --git a/frontend/src-tauri/permissions/autogenerated/sync_bind.toml b/frontend/src-tauri/permissions/autogenerated/sync_bind.toml new file mode 100644 index 0000000..8bcced4 --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/sync_bind.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-sync-bind" +description = "Enables the sync_bind command without any pre-configured scope." +commands.allow = ["sync_bind"] + +[[permission]] +identifier = "deny-sync-bind" +description = "Denies the sync_bind command without any pre-configured scope." +commands.deny = ["sync_bind"] diff --git a/frontend/src-tauri/permissions/autogenerated/sync_create_vault.toml b/frontend/src-tauri/permissions/autogenerated/sync_create_vault.toml new file mode 100644 index 0000000..e972a4b --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/sync_create_vault.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-sync-create-vault" +description = "Enables the sync_create_vault command without any pre-configured scope." +commands.allow = ["sync_create_vault"] + +[[permission]] +identifier = "deny-sync-create-vault" +description = "Denies the sync_create_vault command without any pre-configured scope." +commands.deny = ["sync_create_vault"] diff --git a/frontend/src-tauri/permissions/autogenerated/sync_login.toml b/frontend/src-tauri/permissions/autogenerated/sync_login.toml new file mode 100644 index 0000000..5f9bc2a --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/sync_login.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-sync-login" +description = "Enables the sync_login command without any pre-configured scope." +commands.allow = ["sync_login"] + +[[permission]] +identifier = "deny-sync-login" +description = "Denies the sync_login command without any pre-configured scope." +commands.deny = ["sync_login"] diff --git a/frontend/src-tauri/permissions/autogenerated/sync_logout.toml b/frontend/src-tauri/permissions/autogenerated/sync_logout.toml new file mode 100644 index 0000000..c430110 --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/sync_logout.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-sync-logout" +description = "Enables the sync_logout command without any pre-configured scope." +commands.allow = ["sync_logout"] + +[[permission]] +identifier = "deny-sync-logout" +description = "Denies the sync_logout command without any pre-configured scope." +commands.deny = ["sync_logout"] diff --git a/frontend/src-tauri/permissions/autogenerated/sync_pause.toml b/frontend/src-tauri/permissions/autogenerated/sync_pause.toml new file mode 100644 index 0000000..9844ef9 --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/sync_pause.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-sync-pause" +description = "Enables the sync_pause command without any pre-configured scope." +commands.allow = ["sync_pause"] + +[[permission]] +identifier = "deny-sync-pause" +description = "Denies the sync_pause command without any pre-configured scope." +commands.deny = ["sync_pause"] diff --git a/frontend/src-tauri/permissions/autogenerated/sync_resolve.toml b/frontend/src-tauri/permissions/autogenerated/sync_resolve.toml new file mode 100644 index 0000000..311ddf1 --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/sync_resolve.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-sync-resolve" +description = "Enables the sync_resolve command without any pre-configured scope." +commands.allow = ["sync_resolve"] + +[[permission]] +identifier = "deny-sync-resolve" +description = "Denies the sync_resolve command without any pre-configured scope." +commands.deny = ["sync_resolve"] diff --git a/frontend/src-tauri/permissions/autogenerated/sync_run.toml b/frontend/src-tauri/permissions/autogenerated/sync_run.toml new file mode 100644 index 0000000..af98ef7 --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/sync_run.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-sync-run" +description = "Enables the sync_run command without any pre-configured scope." +commands.allow = ["sync_run"] + +[[permission]] +identifier = "deny-sync-run" +description = "Denies the sync_run command without any pre-configured scope." +commands.deny = ["sync_run"] diff --git a/frontend/src-tauri/permissions/autogenerated/sync_status.toml b/frontend/src-tauri/permissions/autogenerated/sync_status.toml new file mode 100644 index 0000000..99a3ab7 --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/sync_status.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-sync-status" +description = "Enables the sync_status command without any pre-configured scope." +commands.allow = ["sync_status"] + +[[permission]] +identifier = "deny-sync-status" +description = "Denies the sync_status command without any pre-configured scope." +commands.deny = ["sync_status"] diff --git a/frontend/src-tauri/permissions/autogenerated/sync_unbind.toml b/frontend/src-tauri/permissions/autogenerated/sync_unbind.toml new file mode 100644 index 0000000..21491d1 --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/sync_unbind.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-sync-unbind" +description = "Enables the sync_unbind command without any pre-configured scope." +commands.allow = ["sync_unbind"] + +[[permission]] +identifier = "deny-sync-unbind" +description = "Denies the sync_unbind command without any pre-configured scope." +commands.deny = ["sync_unbind"] diff --git a/frontend/src-tauri/permissions/autogenerated/sync_vaults.toml b/frontend/src-tauri/permissions/autogenerated/sync_vaults.toml new file mode 100644 index 0000000..bfac7d2 --- /dev/null +++ b/frontend/src-tauri/permissions/autogenerated/sync_vaults.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-sync-vaults" +description = "Enables the sync_vaults command without any pre-configured scope." +commands.allow = ["sync_vaults"] + +[[permission]] +identifier = "deny-sync-vaults" +description = "Denies the sync_vaults command without any pre-configured scope." +commands.deny = ["sync_vaults"] diff --git a/frontend/src-tauri/src/credentials.rs b/frontend/src-tauri/src/credentials.rs index d598e0e..e8daada 100644 --- a/frontend/src-tauri/src/credentials.rs +++ b/frontend/src-tauri/src/credentials.rs @@ -506,6 +506,7 @@ impl CredentialBroker { self.unlocked.as_ref().ok_or("CREDENTIALS_LOCKED".into()) } pub fn lock(&mut self) { + self.lock_epoch.fetch_add(1, Ordering::SeqCst); self.unlocked.take(); self.ownership.take(); } diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 0c631e8..62fe7a1 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -9,6 +9,8 @@ mod runtime_compat; #[cfg(windows)] pub mod session_lock; #[cfg(feature = "desktop")] +pub mod sync_auth; +#[cfg(feature = "desktop")] pub mod sync_client; pub mod sync_inbox; pub mod sync_resolution; diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index 9931efa..e2ef7c1 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -2,6 +2,9 @@ //! 预览 Host 只开放本地文件命令;未接通的 AI / 同步 / 凭据能力明确返回不可用。 +mod sync_commands; +use sync_commands::*; + use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use notesagent_host::core::CoreSupervisor; use notesagent_host::credentials::CredentialBroker; @@ -18,6 +21,7 @@ use zeroize::Zeroizing; #[derive(Default)] struct Host { requests: Requests, + sync: Arc, workspace: Arc>>, recent: Mutex>, core: Arc>>, @@ -57,7 +61,7 @@ fn host_capabilities(host: State<'_, Host>) -> serde_json::Value { .ok() .and_then(|mut core| core.as_mut().map(|c| c.available())) .unwrap_or(false); - serde_json::json!({"protocol":1,"workspace":true,"core":ready,"sync":false,"credentials":true,"extensions":false,"release":"preview","product":"OpenNexus"}) + serde_json::json!({"protocol":1,"workspace":true,"core":ready,"sync":true,"credentials":true,"extensions":false,"release":"preview","product":"OpenNexus"}) } #[derive(serde::Serialize)] @@ -568,6 +572,7 @@ fn workspace_choose(host: State<'_, Host>) -> Result, String .as_mut() .ok_or("HOST_NOT_READY")? .remember(&result)?; + host.sync.cancel(); *guard = Some(workspace); Ok(Some(result)) } @@ -592,6 +597,7 @@ fn workspace_open(host: State<'_, Host>, path: String) -> Result) -> Result<(), String> { .ok_or("HOST_NOT_READY")? .revoke(&active.root)?; } + host.sync.cancel(); *workspace = None; Ok(()) } @@ -800,6 +807,14 @@ fn main() { } } }); + let sync_handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(5)); + loop { + interval.tick().await; + let _ = sync_commands::run(&sync_handle.state::(), false).await; + } + }); Ok(()) }) .on_menu_event(|app, event| { @@ -816,6 +831,16 @@ fn main() { }) .invoke_handler(tauri::generate_handler![ host_capabilities, + sync_login, + sync_vaults, + sync_create_vault, + sync_bind, + sync_unbind, + sync_pause, + sync_status, + sync_resolve, + sync_logout, + sync_run, credentials_status, credentials_unlock, credentials_lock, diff --git a/frontend/src-tauri/src/sync_auth.rs b/frontend/src-tauri/src/sync_auth.rs new file mode 100644 index 0000000..4c2e2bf --- /dev/null +++ b/frontend/src-tauri/src/sync_auth.rs @@ -0,0 +1,221 @@ +//! Device-local Sync sessions. The serialized record never crosses IPC. +use crate::{ + credentials::{CredentialBroker, CredentialId, Scope}, + sync_client::{Session, SyncClient, SyncError}, + workspace::hash, +}; +use serde::{Deserialize, Serialize}; +use std::{ + future::Future, + sync::{atomic::Ordering, Arc, Mutex}, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use zeroize::Zeroizing; +pub type Credentials = Arc>>; +type Result = std::result::Result; +#[derive(Serialize, Deserialize)] +struct SavedSession { + endpoint: String, + account: String, + allow_test_http: bool, + expires_at: u64, + session: Session, +} +fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} +fn identity(endpoint: &str, account: &str) -> CredentialId { + CredentialId { + scope: Scope::Sync(hash(format!("{endpoint}\n{account}").as_bytes())), + id: "session".into(), + } +} +fn broker( + credentials: &Credentials, + action: impl FnOnce(&mut CredentialBroker) -> std::result::Result, +) -> Result { + let mut guard = credentials + .lock() + .map_err(|_| SyncError::new("HOST_BUSY"))?; + action( + guard + .as_mut() + .ok_or_else(|| SyncError::new("CREDENTIALS_UNAVAILABLE"))?, + ) + .map_err(|e| SyncError::new(&e)) +} +fn save(credentials: &Credentials, saved: &SavedSession) -> Result<()> { + let bytes = Zeroizing::new( + serde_json::to_vec(saved).map_err(|_| SyncError::new("SYNC_SESSION_INVALID"))?, + ); + broker(credentials, |b| { + b.put(&identity(&saved.endpoint, &saved.account), bytes) + }) +} +pub fn available(credentials: &Credentials, endpoint: &str, account: &str) -> Result { + broker(credentials, |b| { + b.resolve( + &identity(endpoint, account).scope, + &identity(endpoint, account), + ) + .map(|v| v.is_some()) + }) +} +/// Dropping a guarded HTTP future closes the in-flight operation on any lock epoch change. +pub async fn guarded( + credentials: &Credentials, + future: impl Future>, +) -> Result { + let (signal, epoch) = broker(credentials, |b| { + if b.is_locked() { + return Err("CREDENTIALS_LOCKED".into()); + } + let signal = b.lock_signal(); + let epoch = signal.load(Ordering::SeqCst); + Ok((signal, epoch)) + })?; + tokio::pin!(future); + let mut interval = tokio::time::interval(Duration::from_millis(50)); + loop { + tokio::select! { + biased; + _=interval.tick()=>{if signal.load(Ordering::SeqCst)!=epoch {return Err(SyncError::new("CREDENTIALS_LOCKED"));}}, + result=&mut future=>{if signal.load(Ordering::SeqCst)!=epoch {return Err(SyncError::new("CREDENTIALS_LOCKED"));} return result;} + } + } +} +pub async fn login( + credentials: &Credentials, + endpoint: &str, + account: &str, + password: Zeroizing, + device: &str, + allow_test_http: bool, +) -> Result { + if account.is_empty() + || account.len() > 128 + || account.contains(['\n', '\r']) + || password.len() > 1024 + || device.is_empty() + || device.len() > 100 + { + return Err(SyncError::new("SYNC_LOGIN_INVALID")); + } + guarded(credentials, async { + let public = SyncClient::new(endpoint, Zeroizing::new(String::new()), allow_test_http)?; + public.handshake().await?; + let session = public.login(account, password, device).await?; + let endpoint = public.endpoint().to_owned(); + save( + credentials, + &SavedSession { + endpoint: endpoint.clone(), + account: account.into(), + allow_test_http, + expires_at: now().saturating_add(session.expires_in), + session, + }, + )?; + Ok(endpoint) + }) + .await +} +/// The caller serializes refreshes with the coordinator gate. +pub async fn client( + credentials: &Credentials, + endpoint: &str, + account: &str, + force_refresh: bool, +) -> Result { + let id = identity(endpoint, account); + let encoded = broker(credentials, |b| b.resolve(&id.scope, &id))? + .ok_or_else(|| SyncError::new("SYNC_LOGIN_REQUIRED"))?; + let mut saved: SavedSession = + serde_json::from_slice(&encoded).map_err(|_| SyncError::new("SYNC_SESSION_INVALID"))?; + if saved.endpoint != endpoint || saved.account != account { + return Err(SyncError::new("SYNC_SESSION_INVALID")); + } + if force_refresh || saved.expires_at <= now().saturating_add(30) { + let public = SyncClient::new( + endpoint, + Zeroizing::new(String::new()), + saved.allow_test_http, + )?; + let refreshed = guarded(credentials, public.refresh(&saved.session.refresh_token)).await?; + if refreshed.device_id != saved.session.device_id { + return Err(SyncError::new("SYNC_SESSION_INVALID")); + } + saved.expires_at = now().saturating_add(refreshed.expires_in); + saved.session = refreshed; + save(credentials, &saved)?; + } + SyncClient::new( + endpoint, + Zeroizing::new(saved.session.access_token.clone()), + saved.allow_test_http, + ) +} +pub async fn logout(credentials: &Credentials, endpoint: &str, account: &str) -> Result<()> { + let client = client(credentials, endpoint, account, false).await?; + // A failed server revocation is reported; the encrypted record remains available for retry. + guarded( + credentials, + client.json(reqwest::Method::DELETE, "sync/v1/auth/sessions", None), + ) + .await?; + broker(credentials, |b| b.delete(&identity(endpoint, account))) +} +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn lock_cancels_inflight_and_scopes_do_not_expose_tokens() { + let root = tempfile::tempdir().unwrap(); + let mut b = CredentialBroker::new(root.path().join("credentials")); + b.unlock(Zeroizing::new(b"test-password-12345".to_vec())) + .unwrap(); + let credentials = Arc::new(Mutex::new(Some(b))); + let saved = SavedSession { + endpoint: "https://sync.example/".into(), + account: "account".into(), + allow_test_http: false, + expires_at: now() + 900, + session: Session { + access_token: "private-access".into(), + refresh_token: "private-refresh".into(), + expires_in: 900, + device_id: "device".into(), + }, + }; + save(&credentials, &saved).unwrap(); + assert!(available(&credentials, &saved.endpoint, &saved.account).unwrap()); + assert!(!available(&credentials, "https://other.example/", &saved.account).unwrap()); + assert!(!available(&credentials, &saved.endpoint, "other").unwrap()); + let bytes = std::fs::read(root.path().join("credentials")).unwrap(); + assert!(!bytes.windows(14).any(|v| v == b"private-access")); + let task_credentials = credentials.clone(); + let pending = tokio::spawn(async move { + guarded(&task_credentials, async { + tokio::time::sleep(Duration::from_secs(30)).await; + Ok(()) + }) + .await + }); + tokio::time::sleep(Duration::from_millis(100)).await; + credentials.lock().unwrap().as_mut().unwrap().lock(); + let result = tokio::time::timeout(Duration::from_secs(1), pending) + .await + .unwrap() + .unwrap(); + assert_eq!(result.unwrap_err().code, "CREDENTIALS_LOCKED"); + assert_eq!( + available(&credentials, &saved.endpoint, &saved.account) + .unwrap_err() + .code, + "CREDENTIALS_LOCKED" + ); + } +} diff --git a/frontend/src-tauri/src/sync_client.rs b/frontend/src-tauri/src/sync_client.rs index 2609188..6506c2c 100644 --- a/frontend/src-tauri/src/sync_client.rs +++ b/frontend/src-tauri/src/sync_client.rs @@ -21,7 +21,7 @@ pub struct SyncError { pub retry_after: Option, } impl SyncError { - fn new(code: &str) -> Self { + pub(crate) fn new(code: &str) -> Self { Self { code: code.into(), status: 0, @@ -197,6 +197,19 @@ impl SyncClient { let value = self.json(Method::POST, "sync/v1/auth/sessions", Some(json!({"username":username,"password":password.as_str(),"device_name":device_name}))).await?; serde_json::from_value(value).map_err(|_| SyncError::new("SYNC_RESPONSE_INVALID")) } + pub fn endpoint(&self) -> &str { + self.endpoint.as_str() + } + pub async fn refresh(&self, refresh_token: &str) -> Result { + let value = self + .json( + Method::POST, + "sync/v1/auth/refresh", + Some(json!({"refresh_token":refresh_token})), + ) + .await?; + serde_json::from_value(value).map_err(|_| SyncError::new("SYNC_RESPONSE_INVALID")) + } pub async fn handshake(&self) -> Result<()> { let result = self .json(Method::GET, "sync/v1/handshake?protocol=1", None) diff --git a/frontend/src-tauri/src/sync_commands.rs b/frontend/src-tauri/src/sync_commands.rs new file mode 100644 index 0000000..7f52080 --- /dev/null +++ b/frontend/src-tauri/src/sync_commands.rs @@ -0,0 +1,358 @@ +//! Main-window commands. Every ongoing run is bound to one Workspace and one account. +use super::{with_workspace, Host}; +use notesagent_host::{ + sync_auth, + sync_client::{SyncError, WorkspaceAccess}, + sync_state::Binding, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicU64, Ordering}, + Mutex, + }, + time::{Duration, Instant}, +}; +use tauri::State; +use zeroize::Zeroizing; +#[derive(Default)] +pub struct Runtime { + gate: tokio::sync::Mutex<()>, + epoch: AtomicU64, + status: Mutex>, +} +#[derive(Default)] +struct Progress { + running: bool, + error: Option, + failures: u32, + retry: Option, + halted: bool, +} +impl Runtime { + pub fn cancel(&self) { + self.epoch.fetch_add(1, Ordering::SeqCst); + } +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Login { + endpoint: String, + account: String, + password: String, + device_name: String, + allow_test_http: bool, +} +#[derive(Serialize)] +pub struct Connection { + endpoint: String, + account: String, +} +#[tauri::command] +pub async fn sync_login(host: State<'_, Host>, request: Login) -> Result { + let _guard = host.sync.gate.lock().await; + let endpoint = sync_auth::login( + &host.credentials, + &request.endpoint, + &request.account, + Zeroizing::new(request.password), + &request.device_name, + request.allow_test_http, + ) + .await + .map_err(|e| e.code)?; + host.sync.status.lock().map_err(|_| "HOST_BUSY")?.clear(); + Ok(Connection { + endpoint, + account: request.account, + }) +} +#[tauri::command] +pub async fn sync_vaults( + host: State<'_, Host>, + endpoint: String, + account: String, +) -> Result { + let _guard = host.sync.gate.lock().await; + let client = sync_auth::client(&host.credentials, &endpoint, &account, false) + .await + .map_err(|e| e.code)?; + sync_auth::guarded( + &host.credentials, + client.json(reqwest::Method::GET, "sync/v1/vaults", None), + ) + .await + .map_err(|e| e.code) +} +#[tauri::command] +pub async fn sync_create_vault( + host: State<'_, Host>, + endpoint: String, + account: String, + name: String, +) -> Result { + if name.trim().is_empty() || name.len() > 100 { + return Err("SYNC_NAME_INVALID".into()); + } + let _guard = host.sync.gate.lock().await; + let client = sync_auth::client(&host.credentials, &endpoint, &account, false) + .await + .map_err(|e| e.code)?; + sync_auth::guarded( + &host.credentials, + client.json( + reqwest::Method::POST, + "sync/v1/vaults", + Some(json!({"name":name})), + ), + ) + .await + .map_err(|e| e.code) +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Bind { + vault_id: String, + endpoint: String, + account: String, + remote_vault: String, + mode: String, +} +#[tauri::command] +pub async fn sync_bind(host: State<'_, Host>, request: Bind) -> Result { + let _guard = host.sync.gate.lock().await; + let client = sync_auth::client( + &host.credentials, + &request.endpoint, + &request.account, + false, + ) + .await + .map_err(|e| e.code)?; + if request.mode == "upload" { + sync_auth::guarded( + &host.credentials, + client.verify_empty(&request.remote_vault), + ) + .await + .map_err(|e| e.code)?; + } else if request.mode == "download" { + // Verify account ownership before creating the durable binding. + let vaults = sync_auth::guarded( + &host.credentials, + client.json(reqwest::Method::GET, "sync/v1/vaults", None), + ) + .await + .map_err(|e| e.code)?; + if !vaults["items"] + .as_array() + .is_some_and(|items| items.iter().any(|v| v["id"] == request.remote_vault)) + { + return Err("SYNC_VAULT_DENIED".into()); + } + } else { + return Err("SYNC_RECONCILIATION_REQUIRED".into()); + } + with_workspace(&host, |ws| { + if ws.vault_id != request.vault_id { + return Err(notesagent_host::workspace::HostError::new("VAULT_CHANGED")); + } + if request.mode == "upload" { + ws.sync_bind_empty(&request.endpoint, &request.remote_vault, &request.account) + } else { + ws.sync_bind_download(&request.endpoint, &request.remote_vault, &request.account) + } + }) +} +#[tauri::command] +pub fn sync_unbind(host: State<'_, Host>, binding_id: String) -> Result<(), String> { + host.sync.cancel(); + with_workspace(&host, |ws| ws.sync_unbind(&binding_id)) +} +#[tauri::command] +pub fn sync_pause(host: State<'_, Host>, binding_id: String, paused: bool) -> Result<(), String> { + host.sync.cancel(); + with_workspace(&host, |ws| ws.sync_pause(&binding_id, paused))?; + host.sync + .status + .lock() + .map_err(|_| "HOST_BUSY")? + .remove(&binding_id); + Ok(()) +} +#[tauri::command] +pub fn sync_status(host: State<'_, Host>) -> Result { + let snapshot = with_workspace(&host, |ws| { + let binding = ws.sync_binding()?; + let paused = binding + .as_ref() + .map(|b| ws.sync_paused(&b.id)) + .transpose()? + .unwrap_or(false); + let conflicts = binding + .as_ref() + .map(|b| ws.sync_conflicts(&b.id)) + .transpose()? + .unwrap_or_default(); + Ok(( + ws.vault_id.clone(), + binding, + paused, + conflicts, + ws.pending_count()?, + )) + })?; + let (vault_id, binding, paused, conflicts, pending) = snapshot; + let credential_state = if let Some(b) = &binding { + sync_auth::available(&host.credentials, &b.endpoint, &b.account) + .map(|exists| { + if exists { + "ready" + } else { + "SYNC_LOGIN_REQUIRED" + } + .to_owned() + }) + .unwrap_or_else(|e| e.code) + } else { + "unbound".into() + }; + let statuses = host.sync.status.lock().map_err(|_| "HOST_BUSY")?; + let status = binding.as_ref().and_then(|b| statuses.get(&b.id)); + Ok( + json!({"vault_id":vault_id,"binding":binding,"paused":paused,"pending":pending,"conflicts":conflicts,"credential_state":credential_state,"running":status.is_some_and(|s|s.running),"error":status.and_then(|s|s.error.as_ref()),"retry_in":status.and_then(|s|s.retry).map(|time|time.saturating_duration_since(Instant::now()).as_secs())}), + ) +} +#[tauri::command] +pub fn sync_resolve( + host: State<'_, Host>, + binding_id: String, + sequence: i64, + choice: String, + destination: String, + expected: String, +) -> Result<(), String> { + with_workspace(&host, |ws| { + ws.sync_resolve(&binding_id, sequence, &choice, &destination, &expected) + }) +} +#[tauri::command] +pub async fn sync_logout( + host: State<'_, Host>, + endpoint: String, + account: String, +) -> Result<(), String> { + host.sync.cancel(); + let _guard = host.sync.gate.lock().await; + sync_auth::logout(&host.credentials, &endpoint, &account) + .await + .map_err(|e| e.code) +} +#[tauri::command] +pub async fn sync_run(host: State<'_, Host>) -> Result<(), String> { + run(&host, true).await +} +pub async fn run(host: &Host, manual: bool) -> Result<(), String> { + let Ok(_guard) = host.sync.gate.try_lock() else { + return if manual { + Err("SYNC_BUSY".into()) + } else { + Ok(()) + }; + }; + let binding = with_workspace(host, |ws| ws.sync_binding())?.ok_or("SYNC_NOT_BOUND")?; + if with_workspace(host, |ws| ws.sync_paused(&binding.id))? { + return Err("SYNC_PAUSED".into()); + } + { + let mut statuses = host.sync.status.lock().map_err(|_| "HOST_BUSY")?; + let state = statuses.entry(binding.id.clone()).or_default(); + if !manual && (state.halted || state.retry.is_some_and(|v| v > Instant::now())) { + return Ok(()); + } + state.running = true; + state.error = None; + } + let result = cycle(host, &binding).await; + let mut statuses = host.sync.status.lock().map_err(|_| "HOST_BUSY")?; + let state = statuses.entry(binding.id.clone()).or_default(); + state.running = false; + match result { + Ok(()) => { + *state = Progress::default(); + Ok(()) + } + Err(error) => { + state.failures = if error.code == "CREDENTIALS_LOCKED" { + 0 + } else { + state.failures.saturating_add(1) + }; + state.error = Some(error.code.clone()); + state.halted = matches!(error.status, 401 | 403 | 413 | 426 | 507) + || matches!( + error.code.as_str(), + "PROTOCOL_INCOMPATIBLE" | "SYNC_LOGIN_REQUIRED" + ); + state.retry = Some( + Instant::now() + + Duration::from_secs( + error + .retry_after + .unwrap_or(2u64.saturating_pow(state.failures.min(8))) + .clamp(1, 3600), + ), + ); + Err(error.code) + } + } +} +async fn cycle(host: &Host, binding: &Binding) -> Result<(), SyncError> { + let epoch = host.sync.epoch.load(Ordering::SeqCst); + let work = async { + let client = sync_auth::client( + &host.credentials, + &binding.endpoint, + &binding.account, + false, + ) + .await?; + let work = async { + client.handshake().await?; + for _ in 0..10 { + if client.pull_page(&host.workspace, binding).await? == 0 { + break; + } + } + // Finish the fixed incoming window before freezing any new remote base. + if host + .workspace + .access(|ws| ws.sync_boundary(&binding.id))? + .is_some() + { + return Ok(()); + } + for _ in 0..20 { + if !client.push_one(&host.workspace, binding).await? { + break; + } + } + client.pull_page(&host.workspace, binding).await?; + Ok(()) + }; + sync_auth::guarded(&host.credentials, work).await + }; + tokio::pin!(work); + let mut tick = tokio::time::interval(Duration::from_millis(50)); + loop { + tokio::select! {biased; + _=tick.tick()=>{ + if host.sync.epoch.load(Ordering::SeqCst)!=epoch {return Err(SyncError{code:"SYNC_CANCELLED".into(),status:0,retry_after:None});} + host.workspace.access(|ws|ws.sync_paused(&binding.id).map(|_|()))?; + }, + result=&mut work=>return result, + } + } +} diff --git a/frontend/src-tauri/src/sync_inbox.rs b/frontend/src-tauri/src/sync_inbox.rs index 2983384..1865c8b 100644 --- a/frontend/src-tauri/src/sync_inbox.rs +++ b/frontend/src-tauri/src/sync_inbox.rs @@ -335,7 +335,10 @@ impl Workspace { .collect::, _>>()?; rows.into_iter().map(|(sequence,file_id,local_path,local_hash,remote)| { let remote: Value = serde_json::from_str(&remote).map_err(|_| HostError::new("SYNC_RESPONSE_INVALID"))?; - Ok(serde_json::json!({"sequence":sequence,"file_id":file_id,"local_path":local_path,"local_hash":local_hash,"remote":remote})) + let current_path=self.path_for_id(&file_id).unwrap_or_else(|_|local_path.clone()); + let source=self.resolve(¤t_path)?; + let current_hash=if source.is_file() {hash(&fs::read(source)?)} else {String::new()}; + Ok(serde_json::json!({"sequence":sequence,"file_id":file_id,"local_path":local_path,"local_hash":local_hash,"current_path":current_path,"current_hash":current_hash,"remote":remote})) }).collect() } } diff --git a/frontend/src-tauri/src/sync_resolution.rs b/frontend/src-tauri/src/sync_resolution.rs index 5b7a60b..3cd4df5 100644 --- a/frontend/src-tauri/src/sync_resolution.rs +++ b/frontend/src-tauri/src/sync_resolution.rs @@ -35,6 +35,7 @@ impl Workspace { let (path,remote): (String,String) = self.db.query_row("SELECT local_path,remote FROM sync_conflicts WHERE binding=?1 AND sequence=?2 AND state='open'",params![binding,sequence],|r| Ok((r.get(0)?,r.get(1)?)))?; let revision: RemoteRevision = serde_json::from_str(&remote) .map_err(|_| HostError::new("SYNC_RESPONSE_INVALID"))?; + let path = self.path_for_id(&revision.file_id).unwrap_or(path); let source = self.resolve(&path)?; let current = if source.is_file() { self.sync_store_bytes(&fs::read(source)?)? diff --git a/frontend/src-tauri/src/sync_state.rs b/frontend/src-tauri/src/sync_state.rs index 84b6231..647a2ad 100644 --- a/frontend/src-tauri/src/sync_state.rs +++ b/frontend/src-tauri/src/sync_state.rs @@ -28,6 +28,23 @@ pub struct Job { } impl Workspace { + pub fn sync_paused(&self, binding: &str) -> Result { + self.check_binding(binding)?; + Ok(self + .db + .query_row( + "SELECT paused FROM sync_preferences WHERE binding=?1", + [binding], + |r| r.get(0), + ) + .optional()? + .unwrap_or(false)) + } + pub fn sync_pause(&mut self, binding: &str, paused: bool) -> Result<()> { + self.check_binding(binding)?; + self.db.execute("INSERT INTO sync_preferences VALUES (?1,?2) ON CONFLICT(binding) DO UPDATE SET paused=excluded.paused",params![binding,paused])?; + Ok(()) + } pub fn sync_binding(&self) -> Result> { Ok(self.db.query_row("SELECT id,endpoint,remote_vault,account,cursor FROM sync_bindings WHERE state='active'", [], |r| { Ok(Binding { id:r.get(0)?, endpoint:r.get(1)?, remote_vault:r.get(2)?, account:r.get(3)?, cursor:r.get(4)? }) diff --git a/frontend/src-tauri/src/workspace.rs b/frontend/src-tauri/src/workspace.rs index 293a088..b38eea9 100644 --- a/frontend/src-tauri/src/workspace.rs +++ b/frontend/src-tauri/src/workspace.rs @@ -18,7 +18,7 @@ pub struct HostError { pub type Result = std::result::Result; impl HostError { - pub(crate) fn new(code: &str) -> Self { + pub fn new(code: &str) -> Self { Self { code: code.into(), message: code.into(), @@ -135,10 +135,10 @@ impl Workspace { let db = Connection::open(db_path)?; db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?; let version: i64 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?; - if version > 5 { + if version > 6 { return Err(HostError::new("SCHEMA_INCOMPATIBLE")); } - if (1..5).contains(&version) { + if (1..6).contains(&version) { // Independent, complete SQLite backup before the schema ownership change. let backup = managed.join(format!("host-schema{version}-{}.sqlite3", Uuid::new_v4())); db.execute("VACUUM INTO ?1", [backup.to_string_lossy().as_ref()])?; @@ -157,6 +157,7 @@ impl Workspace { CREATE TABLE IF NOT EXISTS sync_windows (binding TEXT PRIMARY KEY,boundary INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS sync_inbox (binding TEXT NOT NULL,sequence INTEGER NOT NULL,revision TEXT NOT NULL,operation_id TEXT NOT NULL,rename_id TEXT NOT NULL,state TEXT NOT NULL,PRIMARY KEY(binding,sequence)); CREATE TABLE IF NOT EXISTS sync_conflicts (binding TEXT NOT NULL,sequence INTEGER NOT NULL,file_id TEXT NOT NULL,local_path TEXT NOT NULL,local_hash TEXT NOT NULL,remote TEXT NOT NULL,state TEXT NOT NULL,PRIMARY KEY(binding,sequence)); + CREATE TABLE IF NOT EXISTS sync_preferences (binding TEXT PRIMARY KEY,paused INTEGER NOT NULL DEFAULT 0); CREATE TABLE IF NOT EXISTS sync_resolutions (binding TEXT NOT NULL,sequence INTEGER NOT NULL,choice TEXT NOT NULL,destination TEXT NOT NULL,expected TEXT NOT NULL,operation_id TEXT NOT NULL,rename_id TEXT NOT NULL,copy_id TEXT NOT NULL,state TEXT NOT NULL,PRIMARY KEY(binding,sequence));")?; let has_origin: bool = db.query_row( "SELECT EXISTS(SELECT 1 FROM pragma_table_info('file_ops') WHERE name='origin')", @@ -169,7 +170,7 @@ impl Workspace { [], )?; } - db.execute_batch("PRAGMA user_version=5; COMMIT;")?; + db.execute_batch("PRAGMA user_version=6; COMMIT;")?; let vault_id: String = db .query_row("SELECT id FROM identity", [], |r| r.get(0)) .optional()? diff --git a/frontend/src-tauri/tests/sync_push.rs b/frontend/src-tauri/tests/sync_push.rs index 5ddc86c..3426140 100644 --- a/frontend/src-tauri/tests/sync_push.rs +++ b/frontend/src-tauri/tests/sync_push.rs @@ -303,4 +303,59 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate ); } } + // Host sessions survive encrypted storage reopen and refresh on the actual service. + use notesagent_host::{credentials::CredentialBroker, sync_auth}; + let credential_root = tempfile::tempdir().unwrap(); + let credential_path = credential_root.path().join("credentials.onxcred"); + let mut broker = CredentialBroker::new(credential_path.clone()); + broker + .unlock(Zeroizing::new(b"fixture-stronghold-password".to_vec())) + .unwrap(); + let credentials = Arc::new(Mutex::new(Some(broker))); + let canonical = sync_auth::login( + &credentials, + &endpoint, + "rust-fixture", + Zeroizing::new("controlled-fixture-password".into()), + "Encrypted Host", + true, + ) + .await + .unwrap(); + let host_client = sync_auth::client(&credentials, &canonical, "rust-fixture", true) + .await + .unwrap(); + assert!(host_client + .json(reqwest::Method::GET, "sync/v1/vaults", None) + .await + .unwrap()["items"] + .as_array() + .unwrap() + .iter() + .any(|v| v["id"] == remote)); + credentials.lock().unwrap().as_mut().unwrap().lock(); + *credentials.lock().unwrap() = Some(CredentialBroker::new(credential_path)); + credentials + .lock() + .unwrap() + .as_mut() + .unwrap() + .unlock(Zeroizing::new(b"fixture-stronghold-password".to_vec())) + .unwrap(); + let restored = sync_auth::client(&credentials, &canonical, "rust-fixture", false) + .await + .unwrap(); + restored.handshake().await.unwrap(); + sync_auth::logout(&credentials, &canonical, "rust-fixture") + .await + .unwrap(); + assert!(!sync_auth::available(&credentials, &canonical, "rust-fixture").unwrap()); + assert_eq!( + restored + .json(reqwest::Method::GET, "sync/v1/vaults", None) + .await + .unwrap_err() + .status, + 401 + ); } diff --git a/frontend/src/features/settings/SettingsView.vue b/frontend/src/features/settings/SettingsView.vue index 3c044e3..977a5dc 100644 --- a/frontend/src/features/settings/SettingsView.vue +++ b/frontend/src/features/settings/SettingsView.vue @@ -12,6 +12,7 @@ import ProviderLogo from './ProviderLogo.vue' import ModelRoutingSettings from './ModelRoutingSettings.vue' import LocalModelSettings from './LocalModelSettings.vue' import UsageCard from './UsageCard.vue' +import SyncSettings from './SyncSettings.vue' import CredentialVaultSettings from './CredentialVaultSettings.vue' import { isDesktop } from '@/services/platform/desktop' import { useProviderStore } from '@/stores/provider' @@ -19,9 +20,10 @@ import { useSettingsStore } from '@/stores/settings' import { useThemeStore } from '@/stores/theme' import { t } from '@/i18n' -type Section = 'general' | 'editor' | 'providers' | 'index' | 'permissions' | 'ai-core' +type Section = 'general' | 'editor' | 'providers' | 'index' | 'permissions' | 'ai-core' | 'sync' const sections = computed>(() => [ { id: 'general', label: t('通用', 'General') }, { id: 'editor', label: t('编辑器', 'Editor') }, { id: 'providers', label: t('模型提供商', 'Model Providers') }, + ...(isDesktop() ? [{ id: 'sync' as const, label: 'Sync' }] : []), { id: 'index', label: t('索引与模型', 'Index and Models') }, { id: 'permissions', label: t('权限', 'Permissions') }, { id: 'ai-core', label: t('AI Core 诊断', 'AI Core Diagnostics') }, ]) const activeSection = ref
('general') @@ -160,6 +162,7 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {

{{ t('权限策略', 'Permission Policy') }}

{{ t('以下为后端当前生效的权限策略;全局策略编辑尚未开放,运行时按实际权限请求确认。', 'These policies are active in the backend. Global policy editing is not yet available; runtime requests are confirmed as needed.') }}

{{ t('尚未获取权限策略,请检查后端连接并重新检测。', 'Permission policy is unavailable. Check the backend connection and try again.') }}

{{ permission }}{{ policy === 'allow' ? t('允许', 'Allow') : policy === 'confirm' ? t('每次确认', 'Confirm each time') : t('拒绝', 'Deny') }}
+

{{ t('AI Core 诊断', 'AI Core Diagnostics') }}

{{ settingsStore.diagnosticsError }}
{{ settingsStore.aiCoreStatus }}

{{ t('AI Core 连接状态', 'AI Core connection') }}

{{ t('AI Core 不可用时,Markdown 编辑仍可继续使用。', 'Markdown editing remains available when AI Core is offline.') }}

{{ settingsStore.aiCoreAddress }}

{{ t('开发 API 地址', 'Development API address') }}

{{ t('正式桌面环境由 Sidecar Manager 动态提供。', 'The desktop build will provide this through Sidecar Manager.') }}

{{ t('当前 Web 端不支持重启后端进程,请在运行后端的终端中操作。', 'The web build cannot restart the backend. Use the terminal running it.') }}
diff --git a/frontend/src/features/settings/SyncSettings.spec.ts b/frontend/src/features/settings/SyncSettings.spec.ts new file mode 100644 index 0000000..35194cb --- /dev/null +++ b/frontend/src/features/settings/SyncSettings.spec.ts @@ -0,0 +1,37 @@ +// @vitest-environment happy-dom +import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, expect, it, vi } from 'vitest' +import { hostInvoke } from '@/services/platform/desktop' +import SyncSettings from './SyncSettings.vue' +vi.mock('@/services/platform/desktop', () => ({ hostInvoke: vi.fn() })) +const confirm = vi.hoisted(() => vi.fn()) +vi.mock('@/composables/useActionDialog', () => ({ useActionDialog: () => ({ actionDialog: null, resolveAction: vi.fn(), askConfirm: confirm }) })) +afterEach(() => { vi.useRealTimers(); vi.resetAllMocks() }) +const empty = () => ({ vault_id: 'local', binding: null, paused: false, pending: 0, conflicts: [], credential_state: 'unbound', running: false, error: null, retry_in: null }) +it('clears login password, explicitly opts into HTTP and stops polling on unmount', async () => { + vi.useFakeTimers() + vi.mocked(hostInvoke).mockImplementation(async command => command === 'sync_status' ? empty() : command === 'sync_login' ? { endpoint: 'http://test.example:18080/', account: 'test' } : { items: [] }) + const wrapper = mount(SyncSettings); await flushPromises() + await wrapper.get('input[type=url]').setValue('http://test.example:18080') + await wrapper.get('input[autocomplete=username]').setValue('test') + await wrapper.get('input[type=password]').setValue('private-fixture') + await wrapper.get('input[type=checkbox]').setValue(true) + await wrapper.get('form').trigger('submit'); await flushPromises() + expect(hostInvoke).toHaveBeenCalledWith('sync_login', { request: { endpoint: 'http://test.example:18080', account: 'test', password: 'private-fixture', device_name: 'OpenNexus Desktop', allow_test_http: true } }) + expect((wrapper.get('input[type=password]').element as HTMLInputElement).value).toBe('') + expect(wrapper.text()).not.toContain('private-fixture') + wrapper.unmount(); const count = vi.mocked(hostInvoke).mock.calls.length + await vi.advanceTimersByTimeAsync(6000); expect(hostInvoke).toHaveBeenCalledTimes(count) +}) +it('cancels destructive choices and binds accepted conflict decisions to their snapshot', async () => { + const state = { ...empty(), binding: { id: 'binding', endpoint: 'https://test.example/', account: 'test', remote_vault: 'remote', cursor: 7 }, credential_state: 'ready', conflicts: [{ sequence: 7, local_path: 'note.md', local_hash: 'original-hash', remote: { path: 'note.md', operation: 'put' } }] } + vi.mocked(hostInvoke).mockResolvedValue(state) + const wrapper = mount(SyncSettings); await flushPromises() + const button = wrapper.findAll('button').find(button => button.text() === '采用远端')! + confirm.mockResolvedValue(false); await button.trigger('click'); await flushPromises() + expect(vi.mocked(hostInvoke).mock.calls.some(([command]) => command === 'sync_resolve')).toBe(false) + confirm.mockResolvedValue(true); await button.trigger('click'); await flushPromises() + expect(hostInvoke).toHaveBeenCalledWith('sync_resolve', { bindingId: 'binding', sequence: 7, choice: 'remote', destination: '', expected: 'original-hash' }) + expect(wrapper.get('input[type=url]').attributes('disabled')).toBeDefined() + wrapper.unmount() +}) diff --git a/frontend/src/features/settings/SyncSettings.vue b/frontend/src/features/settings/SyncSettings.vue new file mode 100644 index 0000000..320ea19 --- /dev/null +++ b/frontend/src/features/settings/SyncSettings.vue @@ -0,0 +1,120 @@ + + +