fractal/components/crypto/
recovery_setup_view.rs

1use adw::{prelude::*, subclass::prelude::*};
2use gettextrs::gettext;
3use gtk::{glib, glib::closure_local};
4use matrix_sdk::encryption::{
5    recovery::{RecoveryError, RecoveryState as SdkRecoveryState},
6    secret_storage::SecretStorageError,
7};
8use tracing::{debug, error, warn};
9
10use crate::{
11    components::{AuthDialog, AuthError, LoadingButton, SwitchLoadingRow},
12    session::{RecoveryState, Session},
13    spawn_tokio, toast,
14};
15
16/// A page of the [`CryptoRecoverySetupView`] navigation stack.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::EnumString, strum::AsRefStr)]
18#[strum(serialize_all = "kebab-case")]
19enum CryptoRecoverySetupPage {
20    /// Use account recovery.
21    Recover,
22    /// Reset the recovery and optionally the cross-signing.
23    Reset,
24    /// Enable recovery.
25    Enable,
26    /// The recovery was successfully enabled.
27    Success,
28    /// The recovery was successful but is still incomplete.
29    Incomplete,
30}
31
32/// The initial page of the [`CryptoRecoverySetupView`].
33#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, glib::Enum, strum::AsRefStr)]
34#[enum_type(name = "CryptoRecoverySetupInitialPage")]
35#[strum(serialize_all = "kebab-case")]
36pub enum CryptoRecoverySetupInitialPage {
37    /// Use account recovery.
38    #[default]
39    Recover,
40    /// Reset the account recovery recovery.
41    Reset,
42    /// Enable recovery.
43    Enable,
44}
45
46mod imp {
47    use std::sync::LazyLock;
48
49    use glib::subclass::{InitializingObject, Signal};
50
51    use super::*;
52
53    #[derive(Debug, Default, gtk::CompositeTemplate, glib::Properties)]
54    #[template(resource = "/org/gnome/Fractal/ui/components/crypto/recovery_setup_view.ui")]
55    #[properties(wrapper_type = super::CryptoRecoverySetupView)]
56    pub struct CryptoRecoverySetupView {
57        #[template_child]
58        navigation: TemplateChild<adw::NavigationView>,
59        #[template_child]
60        recover_entry: TemplateChild<adw::PasswordEntryRow>,
61        #[template_child]
62        recover_btn: TemplateChild<LoadingButton>,
63        #[template_child]
64        reset_page: TemplateChild<adw::NavigationPage>,
65        #[template_child]
66        reset_identity_row: TemplateChild<SwitchLoadingRow>,
67        #[template_child]
68        reset_backup_row: TemplateChild<SwitchLoadingRow>,
69        #[template_child]
70        reset_entry: TemplateChild<adw::PasswordEntryRow>,
71        #[template_child]
72        reset_btn: TemplateChild<LoadingButton>,
73        #[template_child]
74        enable_entry: TemplateChild<adw::PasswordEntryRow>,
75        #[template_child]
76        enable_btn: TemplateChild<LoadingButton>,
77        #[template_child]
78        success_description: TemplateChild<gtk::Label>,
79        #[template_child]
80        success_key_box: TemplateChild<gtk::Box>,
81        #[template_child]
82        success_key_label: TemplateChild<gtk::Label>,
83        #[template_child]
84        success_key_copy_btn: TemplateChild<gtk::Button>,
85        #[template_child]
86        success_confirm_btn: TemplateChild<gtk::Button>,
87        #[template_child]
88        incomplete_confirm_btn: TemplateChild<gtk::Button>,
89        /// The current session.
90        #[property(get, set = Self::set_session, construct_only)]
91        session: glib::WeakRef<Session>,
92    }
93
94    #[glib::object_subclass]
95    impl ObjectSubclass for CryptoRecoverySetupView {
96        const NAME: &'static str = "CryptoRecoverySetupView";
97        type Type = super::CryptoRecoverySetupView;
98        type ParentType = adw::Bin;
99
100        fn class_init(klass: &mut Self::Class) {
101            Self::bind_template(klass);
102            Self::bind_template_callbacks(klass);
103
104            klass.set_css_name("setup-view");
105        }
106
107        fn instance_init(obj: &InitializingObject<Self>) {
108            obj.init_template();
109        }
110    }
111
112    #[glib::derived_properties]
113    impl ObjectImpl for CryptoRecoverySetupView {
114        fn signals() -> &'static [Signal] {
115            static SIGNALS: LazyLock<Vec<Signal>> = LazyLock::new(|| {
116                vec![
117                    // Recovery is enabled.
118                    Signal::builder("completed").build(),
119                ]
120            });
121            SIGNALS.as_ref()
122        }
123    }
124
125    impl WidgetImpl for CryptoRecoverySetupView {
126        fn grab_focus(&self) -> bool {
127            match self.visible_page() {
128                CryptoRecoverySetupPage::Recover => self.recover_entry.grab_focus(),
129                CryptoRecoverySetupPage::Reset => self.reset_entry.grab_focus(),
130                CryptoRecoverySetupPage::Enable => self.enable_entry.grab_focus(),
131                CryptoRecoverySetupPage::Success => self.success_confirm_btn.grab_focus(),
132                CryptoRecoverySetupPage::Incomplete => self.incomplete_confirm_btn.grab_focus(),
133            }
134        }
135    }
136
137    impl BinImpl for CryptoRecoverySetupView {}
138
139    #[gtk::template_callbacks]
140    impl CryptoRecoverySetupView {
141        /// The visible page of the view.
142        fn visible_page(&self) -> CryptoRecoverySetupPage {
143            self.navigation
144                .visible_page()
145                .and_then(|p| p.tag())
146                .and_then(|t| t.as_str().try_into().ok())
147                .unwrap()
148        }
149
150        /// Set the current session.
151        fn set_session(&self, session: &Session) {
152            self.session.set(Some(session));
153
154            let security = session.security();
155            let recovery_state = security.recovery_state();
156            let initial_page = match recovery_state {
157                RecoveryState::Unknown | RecoveryState::Disabled
158                    if !security.backup_exists_on_server() =>
159                {
160                    CryptoRecoverySetupInitialPage::Enable
161                }
162                RecoveryState::Unknown | RecoveryState::Disabled | RecoveryState::Enabled => {
163                    CryptoRecoverySetupInitialPage::Reset
164                }
165                RecoveryState::Incomplete => CryptoRecoverySetupInitialPage::Recover,
166            };
167
168            self.update_reset();
169            self.set_initial_page(initial_page);
170        }
171
172        /// Update the reset page for the current state.
173        fn update_reset(&self) {
174            let Some(session) = self.session.upgrade() else {
175                return;
176            };
177
178            let security = session.security();
179            let (required, description) = if security.cross_signing_keys_available() {
180                (
181                    false,
182                    gettext("Invalidates the verifications of all users and sessions"),
183                )
184            } else {
185                (
186                    true,
187                    gettext(
188                        "Required because the crypto identity in the recovery data is incomplete. Invalidates the verifications of all users and sessions.",
189                    ),
190                )
191            };
192            self.reset_identity_row.set_read_only(required);
193            self.reset_identity_row.set_is_active(required);
194            self.reset_identity_row.set_subtitle(&description);
195
196            let (required, description) = if security.backup_enabled() {
197                (
198                    false,
199                    gettext("You might not be able to read your past encrypted messages anymore"),
200                )
201            } else {
202                (
203                    true,
204                    gettext(
205                        "Required because the backup is not set up properly. You might not be able to read your past encrypted messages anymore.",
206                    ),
207                )
208            };
209            self.reset_backup_row.set_read_only(required);
210            self.reset_backup_row.set_is_active(required);
211            self.reset_backup_row.set_subtitle(&description);
212        }
213
214        /// Set the initial page of this view.
215        pub(super) fn set_initial_page(&self, initial_page: CryptoRecoverySetupInitialPage) {
216            self.navigation.replace_with_tags(&[initial_page.as_ref()]);
217        }
218
219        /// Update the success page for the given recovery key.
220        fn update_success(&self, key: Option<String>) {
221            let has_key = key.is_some();
222
223            let description = if has_key {
224                gettext(
225                    "Make sure to store this recovery key in a safe place. You will need it to recover your account if you lose access to all your sessions.",
226                )
227            } else {
228                gettext(
229                    "Make sure to remember your passphrase or to store it in a safe place. You will need it to recover your account if you lose access to all your sessions.",
230                )
231            };
232            self.success_description.set_label(&description);
233
234            if let Some(key) = key {
235                self.success_key_label.set_label(&key);
236            }
237            self.success_key_box.set_visible(has_key);
238        }
239
240        /// Focus the proper widget for the current page.
241        #[template_callback]
242        fn grab_focus(&self) {
243            <Self as WidgetImpl>::grab_focus(self);
244        }
245
246        /// The content of the recover entry changed.
247        #[template_callback]
248        fn recover_entry_changed(&self) {
249            let can_recover = !self.recover_entry.text().is_empty();
250            self.recover_btn.set_sensitive(can_recover);
251        }
252
253        /// Recover the data.
254        #[template_callback]
255        async fn recover(&self) {
256            let Some(session) = self.session.upgrade() else {
257                return;
258            };
259
260            let key = self.recover_entry.text();
261
262            if key.is_empty() {
263                return;
264            }
265
266            self.recover_btn.set_is_loading(true);
267
268            let encryption = session.client().encryption();
269            let recovery = encryption.recovery();
270            let handle = spawn_tokio!(async move { recovery.recover(&key).await });
271
272            match handle.await.unwrap() {
273                Ok(()) => {
274                    // Even if recovery was successful, the recovery data may not have been
275                    // complete. Because the SDK uses multiple threads, we are only
276                    // sure of the SDK's recovery state at this point, not the Session's.
277                    if encryption.recovery().state() == SdkRecoveryState::Incomplete {
278                        self.navigation
279                            .push_by_tag(CryptoRecoverySetupPage::Incomplete.as_ref());
280                    } else {
281                        self.emit_completed();
282                    }
283                }
284                Err(error) => {
285                    error!("Could not recover account: {error}");
286                    let obj = self.obj();
287
288                    match error {
289                        RecoveryError::SecretStorage(SecretStorageError::SecretStorageKey(_)) => {
290                            toast!(obj, gettext("The recovery passphrase or key is invalid"));
291                        }
292                        _ => {
293                            toast!(obj, gettext("Could not access recovery data"));
294                        }
295                    }
296                }
297            }
298
299            self.recover_btn.set_is_loading(false);
300        }
301
302        /// Reset recovery and optionally cross-signing and room keys backup.
303        #[template_callback]
304        async fn reset(&self) {
305            self.reset_btn.set_is_loading(true);
306
307            let reset_identity = self.reset_identity_row.is_active();
308            if reset_identity && self.reset_cross_signing().await.is_err() {
309                self.reset_btn.set_is_loading(false);
310                return;
311            }
312
313            let passphrase = self.reset_entry.text();
314
315            let reset_backup = self.reset_backup_row.is_active();
316            if reset_backup {
317                self.reset_backup_and_recovery(passphrase).await;
318            } else {
319                self.reset_recovery(passphrase).await;
320            }
321
322            self.reset_btn.set_is_loading(false);
323        }
324
325        /// Reset the cross-signing identity.
326        async fn reset_cross_signing(&self) -> Result<(), ()> {
327            let Some(session) = self.session.upgrade() else {
328                return Err(());
329            };
330
331            let dialog = AuthDialog::new(&session);
332            let obj = self.obj();
333
334            let result = dialog.reset_cross_signing(&*obj).await;
335
336            match result {
337                Ok(()) => Ok(()),
338                Err(AuthError::UserCancelled) => {
339                    debug!("User cancelled authentication for cross-signing bootstrap");
340                    Err(())
341                }
342                Err(error) => {
343                    error!("Could not bootstrap cross-signing: {error}");
344                    toast!(obj, gettext("Could not reset the crypto identity"));
345                    Err(())
346                }
347            }
348        }
349
350        /// Reset the room keys backup and the account recovery key.
351        async fn reset_backup_and_recovery(&self, passphrase: glib::GString) {
352            let Some(session) = self.session.upgrade() else {
353                return;
354            };
355
356            let passphrase = Some(passphrase).filter(|s| !s.is_empty());
357            let has_passphrase = passphrase.is_some();
358
359            let obj = self.obj();
360            let encryption = session.client().encryption();
361
362            // There is no method to reset the room keys backup, so we need to disable
363            // recovery and re-enable it.
364            // If backups are not enabled locally, we cannot disable recovery, the API will
365            // return an error. If a backup exists on the homeserver but backups are not
366            // enabled locally, we need to delete the backup manually.
367            // In any case, `Recovery::enable` will reset the secret storage.
368            let backups = encryption.backups();
369            let (backups_are_enabled, backup_exists_on_server) = spawn_tokio!(async move {
370            let backups_are_enabled = backups.are_enabled().await;
371
372            let backup_exists_on_server = if backups_are_enabled {
373                true
374            } else {
375                // Let's use up-to-date data instead of relying on the last time that we updated it.
376                match backups.exists_on_server().await {
377                    Ok(exists) => exists,
378                    Err(error) => {
379                        warn!("Could not request whether recovery backup exists on homeserver: {error}");
380                        // If the request failed, we have to try to delete the backup to avoid unsolvable errors.
381                        true
382                    }
383                }
384            };
385
386            (backups_are_enabled, backup_exists_on_server)
387        })
388        .await
389        .expect("task was not aborted");
390
391            if !backups_are_enabled && backup_exists_on_server {
392                let backups = encryption.backups();
393                let handle = spawn_tokio!(async move { backups.disable_and_delete().await });
394
395                if let Err(error) = handle.await.expect("task was not aborted") {
396                    error!("Could not disable backups: {error}");
397                    toast!(obj, gettext("Could not reset account recovery"));
398                    return;
399                }
400            } else if backups_are_enabled {
401                let recovery = encryption.recovery();
402                let handle = spawn_tokio!(async move { recovery.disable().await });
403
404                if let Err(error) = handle.await.expect("task was not aborted") {
405                    error!("Could not disable recovery: {error}");
406                    toast!(obj, gettext("Could not reset account recovery"));
407                    return;
408                }
409            }
410
411            let recovery = encryption.recovery();
412            let handle = spawn_tokio!(async move {
413                let mut enable = recovery.enable();
414                if let Some(passphrase) = passphrase.as_deref() {
415                    enable = enable.with_passphrase(passphrase);
416                }
417
418                enable.await
419            });
420
421            match handle.await.unwrap() {
422                Ok(key) => {
423                    let key = (!has_passphrase).then_some(key);
424
425                    self.update_success(key);
426                    self.navigation
427                        .push_by_tag(CryptoRecoverySetupPage::Success.as_ref());
428                }
429                Err(error) => {
430                    error!("Could not re-enable account recovery: {error}");
431                    toast!(obj, gettext("Could not reset account recovery"));
432                }
433            }
434        }
435
436        /// Reset the account recovery key.
437        async fn reset_recovery(&self, passphrase: glib::GString) {
438            let Some(session) = self.session.upgrade() else {
439                return;
440            };
441
442            let passphrase = Some(passphrase).filter(|s| !s.is_empty());
443            let has_passphrase = passphrase.is_some();
444
445            let recovery = session.client().encryption().recovery();
446            let handle = spawn_tokio!(async move {
447                let mut reset = recovery.reset_key();
448                if let Some(passphrase) = passphrase.as_deref() {
449                    reset = reset.with_passphrase(passphrase);
450                }
451
452                reset.await
453            });
454
455            match handle.await.unwrap() {
456                Ok(key) => {
457                    let key = (!has_passphrase).then_some(key);
458
459                    self.update_success(key);
460                    self.navigation
461                        .push_by_tag(CryptoRecoverySetupPage::Success.as_ref());
462                }
463                Err(error) => {
464                    error!("Could not reset account recovery key: {error}");
465                    let obj = self.obj();
466                    toast!(obj, gettext("Could not reset account recovery key"));
467                }
468            }
469        }
470
471        /// Enable recovery.
472        #[template_callback]
473        async fn enable(&self) {
474            let Some(session) = self.session.upgrade() else {
475                return;
476            };
477
478            self.enable_btn.set_is_loading(true);
479
480            let passphrase = Some(self.enable_entry.text()).filter(|s| !s.is_empty());
481            let has_passphrase = passphrase.is_some();
482
483            let recovery = session.client().encryption().recovery();
484            let handle = spawn_tokio!(async move {
485                let mut enable = recovery.enable();
486                if let Some(passphrase) = passphrase.as_deref() {
487                    enable = enable.with_passphrase(passphrase);
488                }
489
490                enable.await
491            });
492
493            match handle.await.unwrap() {
494                Ok(key) => {
495                    let key = if has_passphrase { None } else { Some(key) };
496
497                    self.update_success(key);
498                    self.navigation
499                        .push_by_tag(CryptoRecoverySetupPage::Success.as_ref());
500                }
501                Err(error) => {
502                    error!("Could not enable account recovery: {error}");
503                    let obj = self.obj();
504                    toast!(obj, gettext("Could not enable account recovery"));
505                }
506            }
507
508            self.enable_btn.set_is_loading(false);
509        }
510
511        /// Copy the recovery key to the clipboard.
512        #[template_callback]
513        fn copy_key(&self) {
514            let obj = self.obj();
515            let key = self.success_key_label.label();
516
517            let clipboard = obj.clipboard();
518            clipboard.set_text(&key);
519
520            toast!(obj, "Recovery key copied to clipboard");
521        }
522
523        // Emit the `completed` signal.
524        #[template_callback]
525        fn emit_completed(&self) {
526            self.obj().emit_by_name::<()>("completed", &[]);
527        }
528
529        // Show the reset page, after updating it.
530        #[template_callback]
531        fn show_reset(&self) {
532            self.update_reset();
533            self.navigation
534                .push_by_tag(CryptoRecoverySetupPage::Reset.as_ref());
535        }
536    }
537}
538
539glib::wrapper! {
540    /// A view with the different flows to use or set up account recovery.
541    pub struct CryptoRecoverySetupView(ObjectSubclass<imp::CryptoRecoverySetupView>)
542        @extends gtk::Widget, adw::Bin,
543        @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget;
544}
545
546impl CryptoRecoverySetupView {
547    pub fn new(session: &Session) -> Self {
548        glib::Object::builder().property("session", session).build()
549    }
550
551    /// Set the initial page of this view.
552    pub(crate) fn set_initial_page(&self, initial_page: CryptoRecoverySetupInitialPage) {
553        self.imp().set_initial_page(initial_page);
554    }
555
556    /// Connect to the signal emitted when the recovery was successfully
557    /// enabled.
558    pub fn connect_completed<F: Fn(&Self) + 'static>(&self, f: F) -> glib::SignalHandlerId {
559        self.connect_closure(
560            "completed",
561            true,
562            closure_local!(move |obj: Self| {
563                f(&obj);
564            }),
565        )
566    }
567}