409 lines
12 KiB
Rust
409 lines
12 KiB
Rust
//! 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,
|
|
#[serde(default)]
|
|
fingerprint: Option<String>,
|
|
}
|
|
#[tauri::command]
|
|
pub async fn sync_preview(
|
|
host: State<'_, Host>,
|
|
request: Bind,
|
|
) -> Result<notesagent_host::sync_initial::Preview, 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)?;
|
|
let snapshot = sync_auth::guarded(&host.credentials, client.snapshot(&request.remote_vault))
|
|
.await
|
|
.map_err(|e| e.code)?;
|
|
with_workspace(&host, |ws| {
|
|
if ws.vault_id != request.vault_id {
|
|
return Err(notesagent_host::workspace::HostError::new("VAULT_CHANGED"));
|
|
}
|
|
ws.sync_preview(
|
|
&request.endpoint,
|
|
&request.remote_vault,
|
|
&request.account,
|
|
&snapshot,
|
|
)
|
|
})
|
|
}
|
|
#[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 == "merge" {
|
|
let snapshot =
|
|
sync_auth::guarded(&host.credentials, client.snapshot(&request.remote_vault))
|
|
.await
|
|
.map_err(|e| e.code)?;
|
|
return with_workspace(&host, |ws| {
|
|
if ws.vault_id != request.vault_id {
|
|
return Err(notesagent_host::workspace::HostError::new("VAULT_CHANGED"));
|
|
}
|
|
ws.sync_bind_initial(
|
|
&request.endpoint,
|
|
&request.remote_vault,
|
|
&request.account,
|
|
&snapshot,
|
|
request.fingerprint.as_deref().unwrap_or(""),
|
|
)
|
|
});
|
|
}
|
|
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?;
|
|
host.workspace.access(|ws| ws.sync_discover(&binding.id))?;
|
|
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,
|
|
}
|
|
}
|
|
}
|