//! Tauri commands for pairing with a server (M10.7a). //! //! Linking is opt-in and reversible; the app is fully usable having never touched //! any of this. The Settings UI (M10.7e) drives these. use serde::{Deserialize, Serialize}; use tauri::State; use thoughtsync_core::local::Db; use thoughtsync_core::sync::blobs::BlobStore; use thoughtsync_core::sync::client::{self, Identity, ProbeResult}; use thoughtsync_core::sync::compat::Compatibility; use thoughtsync_core::sync::engine; use thoughtsync_core::sync::push; use thoughtsync_core::sync::state; /// Ask a server who it is, without committing to anything. The UI calls this as the /// user finishes typing an address, so they see what answered before handing over /// credentials. #[tauri::command] pub async fn sync_probe(url: String) -> Result { client::probe(&url).await } /// Either a password login or a token pasted from the web app. Both are offered /// because neither covers everyone: a fresh install has no session to mint a token /// from, while someone using a password manager or SSO may prefer not to type a /// password into a desktop app at all. #[derive(Deserialize)] pub struct LinkInput { pub url: String, #[serde(default)] pub email: Option, #[serde(default)] pub password: Option, #[serde(default)] pub token: Option, /// How this device is labelled in the server's device list. #[serde(default)] pub name: Option, } #[derive(Serialize)] pub struct LinkResult { pub status: state::Status, pub identity: Identity, /// Carried through so the UI can warn about a `degraded` server right after /// linking, instead of staying silent until a feature quietly does nothing. pub compatibility: Compatibility, } /// A recognizable default, so a server's device list doesn't fill up with "Device". fn default_device_name() -> String { format!("ThoughtSync desktop ({})", std::env::consts::OS) } fn trimmed(value: &Option) -> Option<&str> { value.as_deref().map(str::trim).filter(|s| !s.is_empty()) } #[tauri::command] pub async fn sync_link(input: LinkInput, db: State<'_, Db>) -> Result { // 1. Handshake FIRST. Never hand credentials to a server we've established we // can't sync with — and an incompatible server is exactly the case where a // later failure would be hardest to attribute. let probe = client::probe(&input.url).await?; if let Compatibility::Incompatible { reason, .. } = &probe.compatibility { return Err(reason.clone()); } let base_url = probe.base_url; // 2. Obtain a credential. let (token, identity) = match trimmed(&input.token) { Some(token) => { // Verify before storing: an unverified paste turns a copy/paste slip // into a failure that only surfaces at the next sync. let identity = client::fetch_identity(&base_url, token).await?; (token.to_string(), identity) } None => { let (Some(email), Some(password)) = (trimmed(&input.email), trimmed(&input.password)) else { return Err("Enter your email and password, or paste a device token.".to_string()); }; let name = trimmed(&input.name) .map(str::to_string) .unwrap_or_else(default_device_name); client::device_login(&base_url, email, password, &name).await? } }; // 3. Persist. The lock is taken only now, for two reasons: a std MutexGuard // isn't Send so it cannot be held across an await, and holding the store // locked for a network round-trip would freeze every note operation in the UI. let status = { let conn = db.0.lock().map_err(|e| e.to_string())?; state::set_link(&conn, &base_url, &token).map_err(|e| e.to_string())?; // Adopt the server's trash-retention window immediately, so the Trash view // stops counting down against this device's offline default the moment it's // no longer the policy in force. if let Some(days) = probe.server.trash_retention_days { state::set_server_retention(&conn, days as i64).map_err(|e| e.to_string())?; } state::status(&conn).map_err(|e| e.to_string())? }; log::info!("linked to {} as {}", base_url, identity.email); Ok(LinkResult { status, identity, compatibility: probe.compatibility, }) } #[derive(Serialize)] pub struct UnlinkResult { pub status: state::Status, /// What happened to the token on the SERVER — kept separate from `status` /// because the local half always succeeds and the remote half may not. pub revoked: client::RevokeOutcome, } /// Stop syncing, and retire this device's token on the server. /// /// The local half is unconditional. Someone unlinking because the machine is being /// sold or handed on must not be held to it by a server that's offline or gone — so /// the revoke is attempted first, its outcome carried back for the UI to report /// honestly, and the link cleared either way. #[tauri::command] pub async fn sync_unlink(db: State<'_, Db>) -> Result { // Read and release before the network call: a std MutexGuard isn't Send, and // holding the store across a round-trip would freeze every note operation in // the UI. let link = { let conn = db.0.lock().map_err(|e| e.to_string())?; let current = state::read(&conn).map_err(|e| e.to_string())?; current.server_url.zip(current.device_token) }; let revoked = match &link { Some((base_url, token)) => client::revoke_self(base_url, token).await, None => client::RevokeOutcome::Skipped, }; let conn = db.0.lock().map_err(|e| e.to_string())?; state::clear_link(&conn).map_err(|e| e.to_string())?; log::info!("unlinked from server (server-side token: {revoked:?})"); Ok(UnlinkResult { status: state::status(&conn).map_err(|e| e.to_string())?, revoked, }) } #[tauri::command] pub fn sync_status(db: State<'_, Db>) -> Result { let conn = db.0.lock().map_err(|e| e.to_string())?; state::status(&conn).map_err(|e| e.to_string()) } /// The server URL + token, or a plain "not linked" error. Every networked sync /// command needs exactly this, and none of them may hold the lock past it. fn credentials(db: &State<'_, Db>) -> Result<(String, String), String> { let conn = db.0.lock().map_err(|e| e.to_string())?; let current = state::read(&conn).map_err(|e| e.to_string())?; match (current.server_url, current.device_token) { (Some(url), Some(token)) => Ok((url, token)), _ => Err("This app isn't linked to a server yet.".to_string()), } } /// Run one full sync: push local changes, then pull the server's. /// /// The only sync entry point exposed to the UI, on purpose. Push and pull exist /// separately inside the crate, but offering a bare "pull" would let the UI overwrite /// unsent local edits — the ordering isn't a suggestion, it's what keeps them. #[tauri::command] pub async fn sync_now( db: State<'_, Db>, blobs: State<'_, BlobStore>, ) -> Result { let (base_url, token) = credentials(&db)?; engine::run_cycle(db.inner(), blobs.inner(), &base_url, &token).await } /// Whether anything is waiting to be sent. Lets the UI show an honest "unsynced /// changes" state without running a sync to find out. #[tauri::command] pub fn sync_has_pending(db: State<'_, Db>) -> Result { let conn = db.0.lock().map_err(|e| e.to_string())?; push::has_pending(&conn).map_err(|e| e.to_string()) }