//! 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::{AppHandle, State}; use inkwell_core::local::Db; use inkwell_core::sync::client::{self, Identity, ProbeResult}; use inkwell_core::sync::client::{Member, NoteShare}; use inkwell_core::sync::compat::Compatibility; use inkwell_core::sync::engine; use inkwell_core::sync::push; use inkwell_core::sync::sharing; use inkwell_core::sync::state; use crate::autosync::{self, AutoSync, LastCycle}; /// 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!("Inkwell 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()) } /// 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. /// /// Runs on the background worker (autosync.rs) and waits for it, so a button press /// can never overlap an automatic cycle. #[tauri::command] pub async fn sync_now(app: AppHandle) -> Result { autosync::run_now(&app).await } /// The most recent cycle, automatic or not — what the Sync screen shows now that /// most cycles happen without anyone pressing anything. None until the first one. #[tauri::command] pub fn sync_last(autosync: State<'_, AutoSync>) -> Option { autosync.last() } /// 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()) } // --- sharing (#5175) ----------------------------------------------------------- // // The Share dialog's calls, straight to the linked server. Unlinked, each answers // `sharing::NEEDS_SERVER`, which the dialog shows as it is. #[tauri::command] pub async fn shares_directory(db: State<'_, Db>) -> Result, String> { sharing::directory(&db).await } #[tauri::command] pub async fn shares_list(note_id: String, db: State<'_, Db>) -> Result, String> { sharing::list(&db, ¬e_id).await } #[tauri::command] pub async fn shares_share( note_id: String, user_id: String, permission: String, db: State<'_, Db>, ) -> Result, String> { sharing::share(&db, ¬e_id, &user_id, &permission).await } #[tauri::command] pub async fn shares_unshare( note_id: String, share_id: String, db: State<'_, Db>, ) -> Result, String> { sharing::unshare(&db, ¬e_id, &share_id).await }