diff --git a/crates/connetto-client/src/auth.rs b/crates/connetto-client/src/auth.rs index f3b50ac2..4b33efd7 100644 --- a/crates/connetto-client/src/auth.rs +++ b/crates/connetto-client/src/auth.rs @@ -713,27 +713,46 @@ impl NativeAuthenticator { /// /// # Errors /// + /// [`ClientError::Auth`] if the store cannot be read or cleared, and else /// [`ClientError::Transport`] or [`ClientError::Auth`] if the revoke fails, - /// after the local clear. [`ClientError::Auth`] if the store cannot be read - /// or cleared. + /// after the local clear. pub async fn logout(&self) -> Result<(), ClientError> { + let signed_out = self.sign_out().await; + signed_out.credential.and(signed_out.revoke) + } + + /// [`Self::logout`] with the clear and the revoke reported apart. + pub(crate) async fn sign_out(&self) -> SignedOut { // Signing out is per account: any other stored credential survives, and // the marker is left naming an account with none, which the next start // answers with an interactive login rather than by picking somebody else. let Some(account) = self.account() else { - return Ok(()); + return SignedOut::default(); }; - let Some(refresh) = self.store.load(&account).await? else { - return Ok(()); + let refresh = match self.store.load(&account).await { + Ok(Some(refresh)) => refresh, + Ok(None) => return SignedOut::default(), + Err(err) => { + return SignedOut { + credential: Err(err), + revoke: Err(ClientError::Auth( + "the stored credential could not be read, so the session was not revoked" + .to_owned(), + )), + }; + } }; - let revoked = self + let revoke = self .post( &format!("{}/auth/logout", self.server_base), &serde_json::json!({ "refresh_token": refresh }), ) - .await; - self.store.clear(&account).await?; - revoked.map(drop) + .await + .map(drop); + SignedOut { + credential: self.store.clear(&account).await, + revoke, + } } async fn post_json( @@ -779,6 +798,24 @@ impl NativeAuthenticator { } } +/// How each half of a sign-out went. +#[derive(Debug)] +pub(crate) struct SignedOut { + /// Reading and clearing the stored credential. + pub(crate) credential: Result<(), ClientError>, + /// Revoking the session on the server. + pub(crate) revoke: Result<(), ClientError>, +} + +impl Default for SignedOut { + fn default() -> Self { + Self { + credential: Ok(()), + revoke: Ok(()), + } + } +} + /// A 256-bit random token as URL-safe base64, for the PKCE verifier and the /// CSRF state. fn random_token() -> String { @@ -1103,4 +1140,92 @@ mod tests { Some(key_from_byte(0x22)) ); } + + /// A refresh store holding one token whose reads or clears fail as told. + struct BrokenRefresh { + load_fails: bool, + } + + impl connetto_core::traits::RefreshTokenStore for BrokenRefresh { + type Error = crate::ClientError; + + fn load<'a>( + &'a self, + _account: &'a str, + ) -> connetto_core::traits::RefreshFuture<'a, Option, crate::ClientError> { + let token = if self.load_fails { + Err(crate::ClientError::Auth( + "the store cannot be read".to_owned(), + )) + } else { + Ok(Some("session-id.secret".to_owned())) + }; + Box::pin(std::future::ready(token)) + } + + fn store<'a>( + &'a self, + _account: &'a str, + _token: &'a str, + ) -> connetto_core::traits::RefreshFuture<'a, (), crate::ClientError> { + Box::pin(std::future::ready(Ok(()))) + } + + fn clear<'a>( + &'a self, + _account: &'a str, + ) -> connetto_core::traits::RefreshFuture<'a, (), crate::ClientError> { + Box::pin(std::future::ready(Err(crate::ClientError::Auth( + "the store cannot be cleared".to_owned(), + )))) + } + + fn accounts( + &self, + ) -> connetto_core::traits::RefreshFuture<'_, Vec, crate::ClientError> { + Box::pin(std::future::ready(Ok(vec!["account".to_owned()]))) + } + + fn protection(&self) -> crate::Custody { + crate::Custody::Unverified(crate::NoGate::Unsupported) + } + } + + /// An authenticator over `store` whose server is unreachable, since + /// nothing listens on port 1. + fn offline_authenticator(store: BrokenRefresh) -> super::NativeAuthenticator { + super::NativeAuthenticator::new( + "http://127.0.0.1:1", + "dev-idp", + std::sync::Arc::new(store), + Some("account".to_owned()), + ) + } + + /// A credential that cannot be cleared is reported as kept, apart from + /// the revoke, which fails on its own. + #[tokio::test] + async fn a_failed_clear_reports_the_credential_apart_from_the_revoke() { + let signed_out = offline_authenticator(BrokenRefresh { load_fails: false }) + .sign_out() + .await; + assert!( + matches!(&signed_out.credential, Err(crate::ClientError::Auth(message)) if message.contains("cleared")), + "the clear failed, got {signed_out:?}" + ); + assert!( + matches!(signed_out.revoke, Err(crate::ClientError::Transport(_))), + "and the revoke failed on the unreachable server" + ); + } + + /// A credential that cannot be read cannot be revoked either. + #[tokio::test] + async fn an_unreadable_credential_is_neither_cleared_nor_revoked() { + let signed_out = offline_authenticator(BrokenRefresh { load_fails: true }) + .sign_out() + .await; + assert!(signed_out.credential.is_err(), "the read failed"); + assert!(signed_out.revoke.is_err(), "so nothing could be revoked"); + } } diff --git a/crates/connetto-client/src/builder/native.rs b/crates/connetto-client/src/builder/native.rs index 5235e217..6b85e3fd 100644 --- a/crates/connetto-client/src/builder/native.rs +++ b/crates/connetto-client/src/builder/native.rs @@ -44,7 +44,7 @@ use crate::reconnect::ReconnectPolicy; use crate::replica::encode_identity; use crate::teardown::content_dir; #[cfg(feature = "native-auth")] -use crate::teardown::{ForgetError, PurgeError, forget_device, wipe_replica}; +use crate::teardown::{ForgetError, ForgetIncomplete, ForgetRefusal, forget_device}; use crate::{ClientError, ConnettoClient, Custody}; #[cfg(feature = "peer")] use crate::{ClientEvent, HotspotError, HotspotOffer, JoinError}; @@ -1473,35 +1473,47 @@ where /// client is closed and its replica swapped for an empty in-memory one /// before the files go, since Windows refuses to delete an open file, so /// clones still alive read an empty replica. The application drops it - /// afterwards. + /// afterwards. Once the client is closed every part of the teardown runs + /// even when an earlier one failed, so as much as possible is removed. /// /// # Errors /// - /// [`ForgetError::NoReplica`] for a build that kept nothing on the - /// device, [`ForgetError::Client`] when the unsynced writes cannot be - /// read or, once the wipe stands, the device key cannot be deleted, - /// [`ForgetError::Purge`] when the guard refuses or the wipe fails, - /// and [`ForgetError::NotRevoked`] when the wipe succeeded but the server - /// was not reached. + /// [`ForgetError::Refused`] when nothing was destroyed and the client + /// still works: the build kept nothing on the device, the unsynced writes + /// cannot be read, or they block the wipe. [`ForgetError::Incomplete`] + /// when the client is closed and part of the teardown failed, naming each + /// part. pub async fn forget_device(&self, force: bool) -> Result<(), ForgetError> { - let teardown = self.teardown.as_ref().ok_or(ForgetError::NoReplica)?; - let unsynced = self.core.client().unsynced().await?; + let teardown = self.teardown.as_ref().ok_or(ForgetRefusal::NoReplica)?; + let unsynced = self + .core + .client() + .unsynced() + .await + .map_err(ForgetRefusal::Unreadable)?; if !unsynced.is_empty() && !force { - return Err(ForgetError::Purge(PurgeError::Unsynced(unsynced))); + return Err(ForgetRefusal::Unsynced(unsynced).into()); } self.core.close().await; - self.core.client().release_replica().await?; - let forgotten = teardown.forget(&unsynced, force).await; + let mut report = ForgetIncomplete { + release: self.core.client().release_replica().await.err(), + ..ForgetIncomplete::default() + }; + teardown.forget(&unsynced, &mut report).await; // The certificate went with the replica, and the key goes once the // wipe stands, whether or not the server heard (lifecycle row // "`forget_device`"). #[cfg(feature = "device-identity")] - if matches!(forgotten, Ok(()) | Err(ForgetError::NotRevoked(_))) + if report.wipe.is_none() && let Some(device) = &self.device { - device.delete_key().await?; + report.device_key = device.delete_key().await.err(); + } + if report.is_complete() { + Ok(()) + } else { + Err(ForgetError::Incomplete(Box::new(report))) } - forgotten } } @@ -1550,8 +1562,8 @@ trait Forget: Send + Sync { fn forget<'a>( &'a self, unsynced: &'a [u64], - force: bool, - ) -> core::pin::Pin> + Send + 'a>>; + report: &'a mut ForgetIncomplete, + ) -> core::pin::Pin + Send + 'a>>; } /// The replica a durable build wrote, and what revokes its session. @@ -1571,32 +1583,16 @@ where fn forget<'a>( &'a self, unsynced: &'a [u64], - force: bool, - ) -> core::pin::Pin> + Send + 'a>> { - Box::pin(async move { - match &self.authenticator { - Some(authenticator) => { - forget_device( - authenticator, - &self.path, - self.key_store.as_ref(), - &self.name, - unsynced, - force, - ) - .await - } - None => wipe_replica( - &self.path, - self.key_store.as_ref(), - &self.name, - unsynced, - force, - ) - .await - .map_err(ForgetError::from), - } - }) + report: &'a mut ForgetIncomplete, + ) -> core::pin::Pin + Send + 'a>> { + Box::pin(forget_device( + self.authenticator.as_deref(), + &self.path, + self.key_store.as_ref(), + &self.name, + unsynced, + report, + )) } } diff --git a/crates/connetto-client/src/teardown.rs b/crates/connetto-client/src/teardown.rs index 2ce8e245..a2de3c53 100644 --- a/crates/connetto-client/src/teardown.rs +++ b/crates/connetto-client/src/teardown.rs @@ -219,62 +219,134 @@ where purge_replica(db_path, unsynced, force) } -/// Failure to forget a device. +/// Failure to forget a device, split by whether the client still works. #[cfg(feature = "native-auth")] #[derive(Debug, thiserror::Error)] pub enum ForgetError { - /// The data teardown failed, so the replica may still be present. + /// Nothing was destroyed and the client still works, so the application + /// keeps it. #[error(transparent)] - Purge(#[from] PurgeError), - /// The local credential was cleared but the server was not reached, so the - /// session stays live until it expires on its own. - #[error("logged out locally but the session was not revoked: {0}")] - NotRevoked(String), + Refused(#[from] ForgetRefusal), + /// The client is closed and part of the teardown failed. The application + /// drops the client and signs in again. + #[error(transparent)] + Incomplete(Box), +} + +/// Why a forget destroyed nothing. +#[cfg(feature = "native-auth")] +#[derive(Debug, thiserror::Error)] +pub enum ForgetRefusal { /// The build kept no replica on this device, so there is nothing to forget. #[error("the build kept no replica on this device")] NoReplica, - /// The client could not read the writes the server has not acknowledged, - /// so nothing was destroyed. + /// The replica holds writes the server has not acknowledged and `force` + /// was not set. + #[error("{} unsynced mutation(s) would be lost", .0.len())] + Unsynced(Vec), + /// The client could not read the writes the server has not acknowledged. #[error("reading the unsynced writes: {0}")] - Client(#[from] crate::ClientError), + Unreadable(crate::ClientError), } -/// Both destructive axes under one guard: revoke and clear the credential, then -/// destroy the key and delete the replica. -/// -/// The one convenience this module offers, and it exists for the ordering rather -/// than the line count. The unsynced guard is checked **first**, before the -/// credential is destroyed, because once the refresh token is gone the queued -/// writes can no longer be uploaded and the guard would be protecting nothing. -/// -/// A revoke that never reached the server does not abort the wipe: the local -/// credential is already gone and the caller asked for the data to go too. The -/// wipe failure wins the report if both fail, because leftover data is the more -/// serious outcome, and [`ForgetError::NotRevoked`] surfaces only once the data -/// is confirmed gone. -/// -/// # Errors +/// What failed in a forget that went past closing the client. Every part runs +/// even when an earlier one failed, except the device key, which goes only +/// once the wipe stands. A part is `None` when it succeeded or had nothing to +/// do, and `device_key` is also `None` when a failed wipe kept the key. +#[cfg(feature = "native-auth")] +#[derive(Debug, Default)] +#[non_exhaustive] +pub struct ForgetIncomplete { + /// Releasing the replica's file handles failed, so a platform that refuses + /// to delete an open file keeps the replica. + pub release: Option, + /// Reading or clearing the stored credential failed, so it may remain. + pub credential: Option, + /// The server was not reached, so the session stays live until it + /// expires on its own. + pub revoke: Option, + /// The replica's key or files could not be removed. + pub wipe: Option, + /// The device key could not be deleted. + pub device_key: Option, +} + +#[cfg(feature = "native-auth")] +impl ForgetIncomplete { + /// Whether every part succeeded. + pub(crate) fn is_complete(&self) -> bool { + self.release.is_none() + && self.credential.is_none() + && self.revoke.is_none() + && self.wipe.is_none() + && self.device_key.is_none() + } +} + +#[cfg(feature = "native-auth")] +impl core::fmt::Display for ForgetIncomplete { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("the client is closed, but")?; + let parts: [(&str, Option<&dyn core::fmt::Display>); 5] = [ + ( + "releasing the replica", + self.release.as_ref().map(|err| err as _), + ), + ( + "clearing the stored credential", + self.credential.as_ref().map(|err| err as _), + ), + ( + "revoking the session", + self.revoke.as_ref().map(|err| err as _), + ), + ("wiping the replica", self.wipe.as_ref().map(|err| err as _)), + ( + "deleting the device key", + self.device_key.as_ref().map(|err| err as _), + ), + ]; + let mut first = true; + for (part, err) in parts { + if let Some(err) = err { + let joint = if first { " " } else { ", and " }; + write!(f, "{joint}{part} failed: {err}")?; + first = false; + } + } + Ok(()) + } +} + +#[cfg(feature = "native-auth")] +impl std::error::Error for ForgetIncomplete {} + +/// Revoke and clear the credential, then destroy the key and delete the +/// replica, filling in the parts of `report` that fail. Both run even when the +/// other fails: a revoke that never reached the server does not keep the data, +/// and data that could not be wiped does not keep the session. /// -/// [`ForgetError::Purge`] if the guard refuses or the wipe fails, or -/// [`ForgetError::NotRevoked`] if the wipe succeeded but the revoke did not. +/// The unsynced guard is the caller's, run before the client closed, because +/// once the refresh token is gone the queued writes can no longer be uploaded. #[cfg(feature = "native-auth")] pub(crate) async fn forget_device( - authenticator: &crate::auth::NativeAuthenticator, + authenticator: Option<&crate::auth::NativeAuthenticator>, db_path: &std::path::Path, key_store: &S, key_name: &str, unsynced: &[u64], - force: bool, -) -> Result<(), ForgetError> -where + report: &mut ForgetIncomplete, +) where S: connetto_core::traits::ReplicaKeyStore, { - if !unsynced.is_empty() && !force { - return Err(ForgetError::Purge(PurgeError::Unsynced(unsynced.to_vec()))); + if let Some(authenticator) = authenticator { + let signed_out = authenticator.sign_out().await; + report.credential = signed_out.credential.err(); + report.revoke = signed_out.revoke.err(); } - let revoked = authenticator.logout().await; - wipe_replica(db_path, key_store, key_name, unsynced, force).await?; - revoked.map_err(|err| ForgetError::NotRevoked(err.to_string())) + report.wipe = wipe_replica(db_path, key_store, key_name, unsynced, true) + .await + .err(); } #[cfg(all(test, feature = "native-transport"))] diff --git a/crates/connetto-client/tests/it/teardown.rs b/crates/connetto-client/tests/it/teardown.rs index 1caba5e2..38ba1b37 100644 --- a/crates/connetto-client/tests/it/teardown.rs +++ b/crates/connetto-client/tests/it/teardown.rs @@ -14,7 +14,7 @@ use std::path::{Path, PathBuf}; use connetto_client::teardown::{ - ForgetError, PurgeError, content_dir, purge_replica, wipe_replica, + ForgetError, ForgetRefusal, PurgeError, content_dir, purge_replica, wipe_replica, }; use connetto_client::{ ClientBuilder, ClientError, DataDir, Gate, NativeClientBuilder, ReplicaKey, SyncSchema, @@ -462,8 +462,10 @@ async fn a_durable_client_forgets_its_device_only_past_the_unsynced_guard() { .await .expect("the offline write is queued"); match client.forget_device(false).await { - Err(ForgetError::Purge(PurgeError::Unsynced(blocked))) => assert_eq!(blocked, queued), - Err(other) => panic!("expected a blocked forget, got {other:?}"), + Err(ForgetError::Refused(ForgetRefusal::Unsynced(blocked))) => { + assert_eq!(blocked, queued); + } + Err(other) => panic!("expected a refused forget, got {other:?}"), Ok(()) => panic!("forget_device must not silently drop queued writes"), } assert!(path.exists(), "the refused forget leaves the replica"); @@ -490,6 +492,95 @@ async fn a_durable_client_forgets_its_device_only_past_the_unsynced_guard() { ); } +/// A key store whose records cannot be cleared, so a wipe fails after the +/// client closed. +#[derive(Clone, Default)] +struct KeysThatStay(super::support::SharedKeys); + +impl ReplicaKeyStore for KeysThatStay { + type Error = ClientError; + + async fn load(&self, name: &str) -> Result, ClientError> { + self.0.load(name).await + } + + async fn store(&self, name: &str, key: &ReplicaKey) -> Result<(), ClientError> { + self.0.store(name, key).await + } + + #[expect( + clippy::unused_async_trait_impl, + reason = "the trait method is async and this one refuses at once" + )] + async fn clear(&self, _name: &str) -> Result<(), ClientError> { + Err(ClientError::Auth( + "the key store refused the clear".to_owned(), + )) + } + + fn protection(&self) -> connetto_client::Custody { + self.0.protection() + } +} + +/// A forget that fails after the client closed says so by kind, names the +/// part that failed, and leaves what that part did not remove. +#[tokio::test] +async fn a_wipe_failing_after_the_close_reports_an_incomplete_forget() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let keys = KeysThatStay::default(); + let credential = super::support::held("alice"); + let record = credential.replica_name().to_owned(); + let path = dir.path().join(&record); + keys.store(&record, &connetto_core::test_support::replica_key()) + .await + .expect("seed the key"); + let client = NativeClientBuilder::new("ws://127.0.0.1:1/", super::support::bundle(SQLITE_DDL)) + .with_dialer(super::support::NeverDial::::default()) + .signed_in(credential) + .durable(dir.path(), keys.clone()) + .with_gate(Gate::off()) + .connect() + .await + .expect("the durable client opens offline"); + + let Err(error @ ForgetError::Incomplete(_)) = client.forget_device(true).await else { + panic!("a wipe failing after the close is an incomplete forget"); + }; + let message = error.to_string(); + assert!( + message.contains("wiping the replica failed") + && message.contains("the key store refused the clear"), + "the message names the failed part and its cause, got {message:?}" + ); + assert!( + ["releasing", "credential", "revoking", "device key"] + .iter() + .all(|part| !message.contains(part)), + "and no part that succeeded, got {message:?}" + ); + let ForgetError::Incomplete(report) = error else { + unreachable!("matched above"); + }; + assert!( + matches!(report.wipe, Some(PurgeError::KeyStore(_))), + "the wipe is the part that failed, got {report:?}" + ); + assert!(report.release.is_none(), "the replica was released"); + assert!( + report.credential.is_none() && report.revoke.is_none(), + "a held credential has no session to revoke" + ); + assert!( + path.exists(), + "the replica stays, since its key could not go" + ); + assert!( + keys.load(&record).await.expect("load").is_some(), + "and so does its key" + ); +} + /// A schema with row-level-security policies still forgets its device, since /// the stand-in that replaces the closed replica holds none of its views. #[tokio::test] @@ -557,7 +648,7 @@ async fn an_in_memory_client_has_no_device_to_forget() { assert!( matches!( client.forget_device(true).await, - Err(ForgetError::NoReplica) + Err(ForgetError::Refused(ForgetRefusal::NoReplica)) ), "an in-memory build refuses to forget" ); diff --git a/docs/architecture/13-client-connection.md b/docs/architecture/13-client-connection.md index 2cf1ee04..d9b695c1 100644 --- a/docs/architecture/13-client-connection.md +++ b/docs/architecture/13-client-connection.md @@ -82,6 +82,8 @@ A spawn announces its boot and keeps answering for it, because a broadcast is no **Native teardown belongs to the client.** A durable `NativeClient` forgets its device with `forget_device(force)`, which reads the unsynced writes before anything is destroyed, closes the client, swaps its replica for an empty in-memory one so no handle keeps the file open, revokes a provider session and wipes the replica, its tier, its content and its key. Windows refuses to delete an open file, which is why the swap comes before the wipe. Clones still alive afterwards read the empty replica. `session()` reports a provider sign-in's user id, account key, deadline and stored accounts, and an account switch builds again with `AccountChoice::Account` or `AccountChoice::New`. +**A failed forget says whether the client still works.** `ForgetError::Refused` means nothing was destroyed, because the build kept no replica, the unsynced writes could not be read, or they block the wipe without `force`, and the application keeps the client. `ForgetError::Incomplete` means the client is closed and its `ForgetIncomplete` names each part that failed: releasing the replica, the stored credential, the revoke, the wipe, and the device key. Every part past the close runs even when an earlier one failed, so as much as possible is removed, except the device key, which goes only once the wipe stands. The application drops the client and signs in again. + **The browser has two terminals on one value.** `WebClientBuilder::new(ws_url, schema)` configures the application once. In the worker, `.signed_in(Auth::new(origin, provider)).durable(prefix).boot()` resolves the sign-in, opens the OPFS entry `{prefix}-{replica name}`, connects, and starts the hub and the services, with the gate on unless the build turns it off. In a page, `.attach(TabTopology)` joins the election, waits for the worker, holds the tab lock, opens the tab's wire and builds the mirror, whose schema is `SyncSchema::relay_mirror()` so both tiers live in `main` where every relayed patch applies. The tab reconnects over fresh wires through `FirstThen` and the tab wire factory. After each tab's ack the worker sends `TabIdentity`, the caller and subjects it is signed in as, and the tab registers them and marks every table changed, so its policy views answer as the worker's replica does. **The trim settings reach OPFS.** `examples/wasm-smoke/tests/opfs_trim.rs` grows a replica from 26 to 1280 pages with 400 rows and deletes them, and with a threshold of 10 and a budget of 10,000 pages the pass returns the file to 26 pages, 106,496 bytes. diff --git a/docs/architecture/14-at-rest-encryption.md b/docs/architecture/14-at-rest-encryption.md index b9b89f92..9b3e53f0 100644 --- a/docs/architecture/14-at-rest-encryption.md +++ b/docs/architecture/14-at-rest-encryption.md @@ -302,7 +302,7 @@ Order is key, tier, replica, content, on both platforms, for the reason the tier Every destructive primitive blocks on pending local work unless `force` is set. In the browser that guard combines unsynced mutation sequence numbers with the content upload outbox, because acknowledged rows can still have bytes waiting to upload. `purge_replica` in `crates/connetto-client/src/teardown.rs` deletes the file without destroying the key and carries the mutation guard. -`forget_device` in `crates/connetto-client/src/teardown.rs` runs both destructive axes under one guard and exists for the ordering guarantee: the unsynced check runs before the credential is destroyed, because once the refresh token is gone the queued writes can no longer be uploaded and the check would be protecting nothing. A revoke that never reached the server does not abort the data wipe. +`NativeClient::forget_device` in `crates/connetto-client/src/builder/native.rs` runs both destructive axes under one guard and exists for the ordering guarantee: the unsynced check runs before the credential is destroyed, because once the refresh token is gone the queued writes can no longer be uploaded and the check would be protecting nothing. A revoke that never reached the server does not abort the data wipe, and a wipe that fails does not keep the credential or the session, so each failure is reported by itself in `ForgetIncomplete`. **A purge that clears the key is unrecoverable.** A `wipe_replica` call destroys the key before the file. If the file delete then fails, `ReplicaUndecryptable` is reported on the next connect. The recovery is `purge_replica` with `force`, which deletes the now-unreadable file so the next connect can first-boot a fresh one. There is no unsynced guard on that recovery: the pending mutations lived inside the file the key will not open, so they are already lost. diff --git a/examples/dioxus-desktop-demo/src/main.rs b/examples/dioxus-desktop-demo/src/main.rs index efe0a7fc..82e469d1 100644 --- a/examples/dioxus-desktop-demo/src/main.rs +++ b/examples/dioxus-desktop-demo/src/main.rs @@ -59,7 +59,7 @@ use std::rc::Rc; use std::sync::Arc; use base64::Engine as _; -use connetto_client::teardown::{ForgetError, PurgeError, expiry_warning}; +use connetto_client::teardown::{ForgetError, ForgetRefusal, expiry_warning}; use connetto_client::{ AccountChoice, Auth, ClientError, ClientEvent, ConnettoClient, Gate, ImportChoices, KeyringAuth, NativeClient, NativeClientBuilder, NativeTransport, SyncSchema, SyncTuning, @@ -351,6 +351,8 @@ impl PartialEq for SessionParts { struct Restart { generation: Signal, choice: Signal, + /// What the session that ended left undone, shown while signing in again. + notice: Signal>, } impl Restart { @@ -359,6 +361,12 @@ impl Restart { self.request_as(AccountChoice::LastUsed); } + /// Start over as the account last used, showing `notice` while signing in. + fn request_noting(mut self, notice: String) { + self.notice.set(Some(notice)); + self.request(); + } + /// Start over as `choice`. fn request_as(mut self, choice: AccountChoice) { self.choice.set(choice); @@ -379,7 +387,12 @@ enum Stage { fn Shell() -> Element { let generation = use_signal(|| 0_u64); let choice = use_signal(|| AccountChoice::LastUsed); - let restart = Restart { generation, choice }; + let mut notice = use_signal(|| None::); + let restart = Restart { + generation, + choice, + notice, + }; let mut stage = use_signal(|| Stage::Starting); use_context_provider(|| restart); let runtime = use_hook(tokio::runtime::Handle::current); @@ -407,7 +420,10 @@ fn Shell() -> Element { )) .await; stage.set(match outcome { - Ok(Ok(parts)) => Stage::Ready(SessionParts(Rc::new(parts))), + Ok(Ok(parts)) => { + notice.set(None); + Stage::Ready(SessionParts(Rc::new(parts))) + } Ok(Err(err)) => Stage::Failed( err.chain() .map(ToString::to_string) @@ -426,6 +442,9 @@ fn Shell() -> Element { div { style: "font-family: system-ui; padding: 20px; line-height: 1.5;", h2 { "Signing in" } + if let Some(notice) = notice() { + p { style: "color: #a33;", {notice} } + } p { "A browser page opens for the login. Come back here once you have signed in." } } }, @@ -1520,7 +1539,9 @@ fn grouped_label(counts: &HashMap) -> String { .join(", ") } -/// Sign out of this account, wiping the replica, and start over. +/// Sign out of this account, wiping the replica, and start over. A refused +/// sign-out keeps the session, and one that closed the client starts over +/// whatever it left undone. async fn sign_out( parts: &SessionParts, discard_unsynced: bool, @@ -1529,12 +1550,17 @@ async fn sign_out( ) { match parts.0.native.forget_device(discard_unsynced).await { Ok(()) => restart.request(), - Err(ForgetError::Purge(PurgeError::Unsynced(seqs))) if !discard_unsynced => { + Err(ForgetError::Refused(ForgetRefusal::Unsynced(seqs))) if !discard_unsynced => { wipe_state.set(WipeState::ConfirmForce { unsynced_count: seqs.len(), }); } - Err(err) => wipe_state.set(WipeState::Error(format!("logout error: {err}"))), + Err(ForgetError::Refused(refusal)) => { + wipe_state.set(WipeState::Error(format!("logout error: {refusal}"))); + } + Err(ForgetError::Incomplete(report)) => { + restart.request_noting(format!("logout error: {report}")); + } } } diff --git a/examples/tauri-leptos-demo/src-tauri/src/lib.rs b/examples/tauri-leptos-demo/src-tauri/src/lib.rs index 185dd3bf..1afdcae1 100644 --- a/examples/tauri-leptos-demo/src-tauri/src/lib.rs +++ b/examples/tauri-leptos-demo/src-tauri/src/lib.rs @@ -9,7 +9,7 @@ use std::path::PathBuf; use std::sync::{Arc, Weak}; -use connetto_client::teardown::{ForgetError, PurgeError}; +use connetto_client::teardown::{ForgetError, ForgetRefusal}; use connetto_client::{ Auth, ClientError, ClientEvent, ConnettoClient, Gate, KeyringAuth, LiveHandle as _, Moment, NativeClient, NativeClientBuilder, NativeTransport, SyncSchema, SyncTuning, SystemClock, @@ -69,6 +69,8 @@ struct View { /// A live query or write that failed. error: Option, wipe: Wipe, + /// What a sign-out that closed the client left undone, shown while signing in again. + notice: Option, } impl Default for View { @@ -84,6 +86,7 @@ impl Default for View { count: None, error: None, wipe: Wipe::Idle, + notice: None, } } } @@ -243,7 +246,9 @@ async fn unlock(core: tauri::State<'_, Arc>) -> Result<(), CommandError> { } /// Revoke the session, wipe the replica and sign in again. Unsynced writes -/// block the wipe until `discard_unsynced` confirms losing them. +/// block the wipe until `discard_unsynced` confirms losing them. A refused +/// sign-out keeps the session, and one that closed the client signs in again +/// whatever it left undone. #[tauri::command(rename_all = "snake_case")] async fn sign_out( app: AppHandle, @@ -251,28 +256,32 @@ async fn sign_out( discard_unsynced: bool, ) -> Result<(), CommandError> { let session = core.begin_sign_out().ok_or(CommandError::NotReady)?; - match session.native.forget_device(discard_unsynced).await { - Ok(()) => { - *core.slot.lock() = Slot::Empty; - drop(session); - core.update(&app, |view| *view = View::default()); - start(app, Arc::clone(&core)); - } - Err(err) => { - let wipe = match err { - ForgetError::Purge(PurgeError::Unsynced(unsynced)) if !discard_unsynced => { - Wipe::ConfirmForce { - unsynced: unsynced.len(), - } - } - err => Wipe::Failed { - message: format!("logout error: {err}"), + let notice = match session.native.forget_device(discard_unsynced).await { + Ok(()) => None, + Err(ForgetError::Incomplete(report)) => Some(format!("logout error: {report}")), + Err(ForgetError::Refused(refusal)) => { + let wipe = match refusal { + ForgetRefusal::Unsynced(unsynced) if !discard_unsynced => Wipe::ConfirmForce { + unsynced: unsynced.len(), + }, + refusal => Wipe::Failed { + message: format!("logout error: {refusal}"), }, }; *core.slot.lock() = Slot::Ready(session); core.update(&app, |view| view.wipe = wipe); + return Ok(()); } - } + }; + *core.slot.lock() = Slot::Empty; + drop(session); + core.update(&app, |view| { + *view = View { + notice, + ..View::default() + }; + }); + start(app, Arc::clone(&core)); Ok(()) } diff --git a/examples/tauri-leptos-demo/ui/src/main.rs b/examples/tauri-leptos-demo/ui/src/main.rs index 03bb0041..134259af 100644 --- a/examples/tauri-leptos-demo/ui/src/main.rs +++ b/examples/tauri-leptos-demo/ui/src/main.rs @@ -27,6 +27,7 @@ struct View { count: Option, error: Option, wipe: Wipe, + notice: Option, } /// The core's `Wipe`, where signing out stands. @@ -173,6 +174,7 @@ fn App() -> impl IntoView { _ => view! {

"Signing in"

+

{move || view.get().notice.unwrap_or_default()}

"A browser page opens for the login. Come back here once you have signed in."

}