feat: 通过加密会话和冲突设置连接桌面 Sync
This commit is contained in:
@@ -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 已通过。
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<sync_commands::Runtime>,
|
||||
workspace: Arc<Mutex<Option<Workspace>>>,
|
||||
recent: Mutex<Option<RecentVaultStore>>,
|
||||
core: Arc<Mutex<Option<CoreSupervisor>>>,
|
||||
@@ -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<Option<RecentVault>, 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<RecentVault, St
|
||||
}
|
||||
let workspace = Workspace::open(Path::new(&authorized.path)).map_err(|e| e.code)?;
|
||||
let result = info(&workspace);
|
||||
host.sync.cancel();
|
||||
*guard = Some(workspace);
|
||||
Ok(result)
|
||||
}
|
||||
@@ -617,6 +623,7 @@ fn workspace_revoke(host: State<'_, Host>) -> 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::<Host>(), 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,
|
||||
|
||||
@@ -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<Mutex<Option<CredentialBroker>>>;
|
||||
type Result<T> = std::result::Result<T, SyncError>;
|
||||
#[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<T>(
|
||||
credentials: &Credentials,
|
||||
action: impl FnOnce(&mut CredentialBroker) -> std::result::Result<T, String>,
|
||||
) -> Result<T> {
|
||||
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<bool> {
|
||||
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<T>(
|
||||
credentials: &Credentials,
|
||||
future: impl Future<Output = Result<T>>,
|
||||
) -> Result<T> {
|
||||
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<String>,
|
||||
device: &str,
|
||||
allow_test_http: bool,
|
||||
) -> Result<String> {
|
||||
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<SyncClient> {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ pub struct SyncError {
|
||||
pub retry_after: Option<u64>,
|
||||
}
|
||||
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<Session> {
|
||||
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)
|
||||
|
||||
@@ -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<HashMap<String, Progress>>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Progress {
|
||||
running: bool,
|
||||
error: Option<String>,
|
||||
failures: u32,
|
||||
retry: Option<Instant>,
|
||||
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<Connection, String> {
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<Binding, String> {
|
||||
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<Value, String> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,7 +335,10 @@ impl Workspace {
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)?)?
|
||||
|
||||
@@ -28,6 +28,23 @@ pub struct Job {
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
pub fn sync_paused(&self, binding: &str) -> Result<bool> {
|
||||
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<Option<Binding>> {
|
||||
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)? })
|
||||
|
||||
@@ -18,7 +18,7 @@ pub struct HostError {
|
||||
pub type Result<T> = std::result::Result<T, HostError>;
|
||||
|
||||
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()?
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Array<{ id: Section; label: string }>>(() => [
|
||||
{ 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<Section>('general')
|
||||
@@ -160,6 +162,7 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
|
||||
|
||||
<div v-else-if="activeSection === 'permissions'" class="panel settings-section"><h2>{{ t('权限策略', 'Permission Policy') }}</h2><p class="muted section-description">{{ t('以下为后端当前生效的权限策略;全局策略编辑尚未开放,运行时按实际权限请求确认。', 'These policies are active in the backend. Global policy editing is not yet available; runtime requests are confirmed as needed.') }}</p><p v-if="!Object.keys(settingsStore.permissionPolicy).length" class="subtle">{{ t('尚未获取权限策略,请检查后端连接并重新检测。', 'Permission policy is unavailable. Check the backend connection and try again.') }}</p><div class="permission-list"><div v-for="(policy, permission) in settingsStore.permissionPolicy" :key="permission" class="setting-row"><span><strong>{{ permission }}</strong></span><span>{{ policy === 'allow' ? t('允许', 'Allow') : policy === 'confirm' ? t('每次确认', 'Confirm each time') : t('拒绝', 'Deny') }}</span></div></div></div>
|
||||
|
||||
<SyncSettings v-else-if="activeSection === 'sync'" />
|
||||
<div v-else class="panel settings-section"><h2>{{ t('AI Core 诊断', 'AI Core Diagnostics') }}</h2><div v-if="settingsStore.diagnosticsError" class="error-banner">{{ settingsStore.diagnosticsError }}</div><div class="diagnostic-grid"><div class="item-card"><span class="badge" :class="{ success: settingsStore.aiCoreStatus === 'running', error: settingsStore.aiCoreStatus === 'error' }">{{ settingsStore.aiCoreStatus }}</span><h3>{{ t('AI Core 连接状态', 'AI Core connection') }}</h3><p class="subtle">{{ t('AI Core 不可用时,Markdown 编辑仍可继续使用。', 'Markdown editing remains available when AI Core is offline.') }}</p></div><div class="item-card"><strong>{{ settingsStore.aiCoreAddress }}</strong><h3>{{ t('开发 API 地址', 'Development API address') }}</h3><p class="subtle">{{ t('正式桌面环境由 Sidecar Manager 动态提供。', 'The desktop build will provide this through Sidecar Manager.') }}</p></div></div><div class="inline-actions diagnostic-actions"><button class="button-primary" @click="settingsStore.loadDiagnostics">{{ t('重新检测', 'Check again') }}</button><span class="subtle">{{ t('当前 Web 端不支持重启后端进程,请在运行后端的终端中操作。', 'The web build cannot restart the backend. Use the terminal running it.') }}</span></div></div>
|
||||
|
||||
<ProviderForm v-if="showProviderForm" :provider="editingProvider" :models="editingProvider ? providerStore.modelsByProvider[editingProvider.provider_id] : []" @close="showProviderForm = false" @saved="providerSaved" />
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { hostInvoke } from '@/services/platform/desktop'
|
||||
import ActionDialog from '@/components/common/ActionDialog.vue'
|
||||
import { useActionDialog } from '@/composables/useActionDialog'
|
||||
import { t } from '@/i18n'
|
||||
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
|
||||
interface Binding { id: string; endpoint: string; account: string; remote_vault: string; cursor: number }
|
||||
interface Conflict { sequence: number; local_path: string; local_hash: string; current_hash?: string; current_path?: string; remote: { path: string; operation: string } }
|
||||
interface Status { vault_id: string; binding: Binding | null; paused: boolean; pending: number; conflicts: Conflict[]; credential_state: string; running: boolean; error: string | null; retry_in: number | null }
|
||||
interface RemoteVault { id: string; name: string; sequence: number; used: number; quota: number }
|
||||
const status = ref<Status | null>(null)
|
||||
const endpoint = ref('https://'), account = ref(''), password = ref(''), device = ref('OpenNexus Desktop'), testHttp = ref(false)
|
||||
const connected = ref(false), busy = ref(false), message = ref(''), remoteVaults = ref<RemoteVault[]>([]), selected = ref(''), newName = ref('')
|
||||
const copies = ref<Record<number, string>>({})
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
let mounted = true
|
||||
async function refresh() {
|
||||
const next = await hostInvoke<Status>('sync_status')
|
||||
if (!mounted) return
|
||||
status.value = next
|
||||
if (next.binding) { endpoint.value = next.binding.endpoint; account.value = next.binding.account; connected.value = next.credential_state === 'ready' }
|
||||
}
|
||||
async function act(action: () => Promise<void>) {
|
||||
if (busy.value) return
|
||||
busy.value = true; message.value = ''
|
||||
try { await action(); await refresh() }
|
||||
catch (error) { message.value = error instanceof Error ? error.message : 'SYNC_FAILED' }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
async function listVaults() {
|
||||
const result = await hostInvoke<{ items: RemoteVault[] }>('sync_vaults', { endpoint: endpoint.value, account: account.value })
|
||||
remoteVaults.value = result.items
|
||||
}
|
||||
function login() {
|
||||
const secret = password.value; password.value = ''
|
||||
return act(async () => {
|
||||
const result = await hostInvoke<{ endpoint: string; account: string }>('sync_login', { request: { endpoint: endpoint.value, account: account.value, password: secret, device_name: device.value, allow_test_http: testHttp.value } })
|
||||
endpoint.value = result.endpoint; account.value = result.account; connected.value = true
|
||||
await listVaults()
|
||||
})
|
||||
}
|
||||
function createVault() { return act(async () => {
|
||||
const result = await hostInvoke<{ vault_id: string }>('sync_create_vault', { endpoint: endpoint.value, account: account.value, name: newName.value })
|
||||
newName.value = ''; await listVaults(); selected.value = result.vault_id
|
||||
}) }
|
||||
async function bind(mode: 'upload' | 'download') {
|
||||
const vaultId = status.value?.vault_id
|
||||
const remote = selected.value
|
||||
if (!vaultId || !remote) return
|
||||
if (!(await askConfirm(mode === 'upload' ? t('将当前本地笔记上传到所选空远端库?', 'Upload current notes to the selected empty remote vault?') : t('将所选远端库下载到当前空本地库?', 'Download the selected remote vault into this empty local vault?')))) return
|
||||
await act(async () => { await hostInvoke('sync_bind', { request: { vault_id: vaultId, endpoint: endpoint.value, account: account.value, remote_vault: remote, mode } }) })
|
||||
}
|
||||
async function unbind() {
|
||||
const binding = status.value?.binding
|
||||
if (!binding || !(await askConfirm(t('解除当前绑定并封存待上传任务?本地文件仍保留。', 'Unbind and archive pending uploads? Local files are retained.')))) return
|
||||
await act(async () => { await hostInvoke('sync_unbind', { bindingId: binding.id }); remoteVaults.value = []; selected.value = '' })
|
||||
}
|
||||
async function resolve(conflict: Conflict, choice: 'local' | 'remote' | 'copy') {
|
||||
const binding = status.value?.binding
|
||||
const destination = choice === 'copy' ? copies.value[conflict.sequence] ?? '' : ''
|
||||
if (!binding || (choice === 'copy' && !destination)) return
|
||||
if (!(await askConfirm(t(`确认解决 ${conflict.local_path} 的冲突?`, `Resolve the conflict for ${conflict.local_path}?`)))) return
|
||||
await act(async () => { await hostInvoke('sync_resolve', { bindingId: binding.id, sequence: conflict.sequence, choice, destination, expected: conflict.current_hash ?? conflict.local_hash }) })
|
||||
}
|
||||
onMounted(() => {
|
||||
void refresh().catch(error => { message.value = error instanceof Error ? error.message : 'SYNC_FAILED' })
|
||||
timer = setInterval(() => { if (!busy.value) void refresh().catch(() => {}) }, 1500)
|
||||
})
|
||||
onUnmounted(() => { mounted = false; clearInterval(timer); password.value = '' })
|
||||
</script>
|
||||
<template>
|
||||
<section class="panel settings-section sync-settings" aria-labelledby="sync-title">
|
||||
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
|
||||
<h2 id="sync-title">OpenNexus Sync</h2>
|
||||
<p>{{ t('同步当前 Vault 的文件。登录前请先解锁设备凭据保险库。', 'Sync files in the current vault. Unlock the device credential vault before signing in.') }}</p>
|
||||
<p class="subtle">{{ t('当前支持本地上传到空远端,或远端下载到空本地。两边都有文件时请先使用独立空库;合并预览仍在开发中。', 'Upload to an empty remote vault, or download into an empty local vault. Initial merge of two populated vaults is still under development.') }}</p>
|
||||
<p v-if="message" class="error-banner" role="alert">{{ message }}</p>
|
||||
<form class="sync-form" @submit.prevent="login">
|
||||
<label>{{ t('服务器地址', 'Server URL') }}<input v-model="endpoint" required :disabled="!!status?.binding || busy" type="url" autocomplete="url" /></label>
|
||||
<label>{{ t('账户', 'Account') }}<input v-model="account" required :disabled="!!status?.binding || busy" autocomplete="username" /></label>
|
||||
<label>{{ t('密码', 'Password') }}<input v-model="password" required type="password" autocomplete="current-password" :disabled="busy" /></label>
|
||||
<label>{{ t('设备名称', 'Device name') }}<input v-model="device" required maxlength="100" :disabled="busy" /></label>
|
||||
<label class="test-http"><input v-model="testHttp" type="checkbox" :disabled="busy" />{{ t('仅测试:允许 HTTP 明文连接', 'Testing only: allow unencrypted HTTP') }}</label>
|
||||
<button class="button-primary" :disabled="busy || !password">{{ t('登录', 'Sign in') }}</button>
|
||||
</form>
|
||||
<div v-if="connected && !status?.binding" class="sync-connect">
|
||||
<button :disabled="busy" @click="act(listVaults)">{{ t('刷新远端库', 'Refresh vaults') }}</button>
|
||||
<label>{{ t('远端库', 'Remote vault') }}<select v-model="selected"><option value="">{{ t('请选择', 'Choose a vault') }}</option><option v-for="vault in remoteVaults" :key="vault.id" :value="vault.id">{{ vault.name }} · {{ vault.sequence }}</option></select></label>
|
||||
<div class="inline-actions"><input v-model="newName" :placeholder="t('新远端库名称', 'New vault name')" /><button :disabled="busy || !newName.trim()" @click="createVault">{{ t('创建远端库', 'Create vault') }}</button></div>
|
||||
<div class="inline-actions"><button :disabled="busy || !selected || !status" @click="bind('upload')">{{ t('上传到空远端', 'Upload to empty remote') }}</button><button :disabled="busy || !selected || !status" @click="bind('download')">{{ t('下载到空本地', 'Download into empty local') }}</button></div>
|
||||
</div>
|
||||
<div v-if="status?.binding" class="sync-bound">
|
||||
<p>{{ status.binding.endpoint }} · {{ status.binding.account }} · {{ status.binding.remote_vault }}</p>
|
||||
<p aria-live="polite">{{ status.paused ? t('已暂停', 'Paused') : status.running ? t('同步中', 'Syncing') : t('等待下一轮同步', 'Waiting for next sync') }} · {{ t('待上传', 'Pending') }} {{ status.pending }} · cursor {{ status.binding.cursor }}</p>
|
||||
<p v-if="status.credential_state !== 'ready'" role="status">{{ status.credential_state }}</p>
|
||||
<p v-if="status.error" role="alert">{{ status.error }}<span v-if="status.retry_in"> · {{ status.retry_in }}s</span></p>
|
||||
<div class="inline-actions">
|
||||
<button :disabled="busy || status.running || status.paused" @click="act(async () => { await hostInvoke('sync_run') })">{{ t('立即同步', 'Sync now') }}</button>
|
||||
<button :disabled="busy" @click="act(async () => { await hostInvoke('sync_pause', { bindingId: status!.binding!.id, paused: !status!.paused }) })">{{ status.paused ? t('继续同步', 'Resume sync') : t('暂停同步', 'Pause sync') }}</button>
|
||||
<button :disabled="busy" @click="unbind">{{ t('解除绑定', 'Unbind') }}</button>
|
||||
<button :disabled="busy" @click="act(async () => { await hostInvoke('sync_logout', { endpoint, account }); connected = false })">{{ t('退出登录', 'Sign out') }}</button>
|
||||
</div>
|
||||
<article v-for="conflict in status.conflicts" :key="conflict.sequence" class="sync-conflict">
|
||||
<h3>{{ conflict.local_path }}</h3><p>{{ t('远端版本', 'Remote revision') }} {{ conflict.sequence }} · {{ conflict.remote.operation }} · {{ conflict.remote.path }}</p>
|
||||
<div class="inline-actions"><button :disabled="busy" @click="resolve(conflict, 'local')">{{ t('保留本地', 'Keep local') }}</button><button :disabled="busy" @click="resolve(conflict, 'remote')">{{ t('采用远端', 'Use remote') }}</button></div>
|
||||
<label>{{ t('副本相对路径', 'Relative copy path') }}<input v-model="copies[conflict.sequence]" placeholder="conflicts/note-copy.md" /></label><button :disabled="busy || !copies[conflict.sequence]" @click="resolve(conflict, 'copy')">{{ t('另存本地副本并采用远端', 'Save local copy and use remote') }}</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<style scoped>
|
||||
.sync-form,.sync-connect,.sync-bound { display:grid;gap:12px;margin-top:16px }
|
||||
.sync-form label,.sync-connect label,.sync-conflict label { display:grid;gap:5px }
|
||||
input,select { padding:8px;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:inherit;min-width:0 }
|
||||
.test-http { display:flex!important;align-items:center }
|
||||
.inline-actions { display:flex;flex-wrap:wrap;gap:8px }
|
||||
.sync-conflict { border:1px solid var(--border-color);padding:14px;border-radius:8px;display:grid;gap:10px }
|
||||
button { padding:7px 12px;cursor:pointer } button:disabled { cursor:default;opacity:.5 }
|
||||
</style>
|
||||
Reference in New Issue
Block a user