From 1f2ddd70d34f91c9ce5edbb9a9968779c329aebe Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 8 Oct 2026 14:16:59 -0400 Subject: [PATCH] Unlinking a device is one flow in the core: link::unlink The desktop's sync_unlink and the ffi's unlink were both written out in full: try the revoke, clear the link either way, and log the outcome. link::unlink(db, held) now does that. Each client reads its link with state::credentials (with its seal) before the await and passes it in. DRY pass #2, batch 1, F3 (#5372). Co-Authored-By: Claude Opus 5.5 --- android/ffi/src/lib.rs | 19 +++++-------------- core/src/sync/link.rs | 26 ++++++++++++++++++++++++-- desktop/src-tauri/src/commands/sync.rs | 13 ++----------- 3 files changed, 31 insertions(+), 27 deletions(-) diff --git a/android/ffi/src/lib.rs b/android/ffi/src/lib.rs index 9c9bd97..97c364f 100644 --- a/android/ffi/src/lib.rs +++ b/android/ffi/src/lib.rs @@ -526,23 +526,14 @@ impl Inkwell { /// so the revoke is attempted first, its outcome returned for the UI to report /// honestly, and the link cleared either way. pub async fn unlink(&self) -> Result { - // Read and release before the network call: a std MutexGuard isn't Send, so - // it cannot be held across an await, and holding the store through a - // round-trip would freeze every note operation in the UI. A token that won't - // open on this phone can't be revoked from here, so it is skipped. - let link = match self.credentials() { - Ok(link) => Some(link), + // A token that won't open on this phone can't be revoked from here, so it + // is skipped. + let held = match self.credentials() { + Ok(held) => Some(held), Err(CoreError::NotLinked) => None, Err(e) => return Err(e), }; - let revoked = match &link { - Some((base_url, token)) => client::revoke_self(base_url, token).await, - None => client::RevokeOutcome::Skipped, - }; - - let conn = self.db.conn().map_err(CoreError::store)?; - state::clear_link(&conn).map_err(CoreError::store)?; - log::info!("unlinked from server (server-side token: {revoked:?})"); + let revoked = link::unlink(&self.db, held).await.map_err(CoreError::store)?; Ok(revoked.into()) } diff --git a/core/src/sync/link.rs b/core/src/sync/link.rs index 08808f3..70c3c47 100644 --- a/core/src/sync/link.rs +++ b/core/src/sync/link.rs @@ -1,4 +1,4 @@ -//! Linking a device to a server. +//! Linking a device to a server, and unlinking it. //! //! One flow for every client. The desktop and Android each wrote it out, the same //! steps in the same order; what differs between them is only how the token is @@ -6,9 +6,10 @@ use rusqlite::Connection; -use super::client::{self, Identity}; +use super::client::{self, Identity, RevokeOutcome}; use super::compat::Compatibility; use super::state::{self, TokenSeal}; +use crate::local::Db; /// How a device proves whose it is. pub enum Credential<'a> { @@ -88,3 +89,24 @@ pub fn store( log::info!("linked to {base_url}"); Ok(()) } + +/// Stop syncing, and retire this device's token on the server. +/// +/// `held` is the link as `state::credentials` read it, which the caller reads and +/// releases before this awaits: a std MutexGuard isn't Send, and holding the store +/// through a round-trip would freeze every note operation. None skips the revoke. +/// +/// The local half is unconditional. Someone unlinking because the device is being +/// sold or handed on must not be held to it by a server that is offline or gone, +/// so the revoke is tried first, its outcome returned for the UI to report +/// honestly, and the link cleared either way. +pub async fn unlink(db: &Db, held: Option<(String, String)>) -> Result { + let revoked = match &held { + Some((base_url, token)) => client::revoke_self(base_url, token).await, + None => RevokeOutcome::Skipped, + }; + let conn = db.conn()?; + state::clear_link(&conn).map_err(|e| e.to_string())?; + log::info!("unlinked from server (server-side token: {revoked:?})"); + Ok(revoked) +} diff --git a/desktop/src-tauri/src/commands/sync.rs b/desktop/src-tauri/src/commands/sync.rs index cd0f04e..63b4a13 100644 --- a/desktop/src-tauri/src/commands/sync.rs +++ b/desktop/src-tauri/src/commands/sync.rs @@ -121,21 +121,12 @@ pub struct UnlinkResult { /// 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 held = { let conn = db.conn()?; state::credentials(&conn, None).map_err(|e| e.to_string())? }; - let revoked = match &link { - Some((base_url, token)) => client::revoke_self(base_url, token).await, - None => client::RevokeOutcome::Skipped, - }; - + let revoked = link::unlink(&db, held).await?; let conn = db.conn()?; - 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,