fractal/components/crypto/
recovery_setup_view.rs

1use adw::{prelude::*, subclass::prelude::*};
2use gettextrs::gettext;
3use gtk::{glib, glib::closure_local, CompositeTemplate};
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::model::{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, 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("Required because the crypto identity in the recovery data is incomplete. Invalidates the verifications of all users and sessions."),
188                )
189            };
190            self.reset_identity_row.set_read_only(required);
191            self.reset_identity_row.set_is_active(required);
192            self.reset_identity_row.set_subtitle(&description);
193
194            let (required, description) = if security.backup_enabled() {
195                (
196                    false,
197                    gettext("You might not be able to read your past encrypted messages anymore"),
198                )
199            } else {
200                (
201                    true,
202                    gettext("Required because the backup is not set up properly. You might not be able to read your past encrypted messages anymore."),
203                )
204            };
205            self.reset_backup_row.set_read_only(required);
206            self.reset_backup_row.set_is_active(required);
207            self.reset_backup_row.set_subtitle(&description);
208        }
209
210        /// Set the initial page of this view.
211        pub(super) fn set_initial_page(&self, initial_page: CryptoRecoverySetupInitialPage) {
212            self.navigation.replace_with_tags(&[initial_page.as_ref()]);
213        }
214
215        /// Update the success page for the given recovery key.
216        fn update_success(&self, key: Option<String>) {
217            let has_key = key.is_some();
218
219            let description = if has_key {
220                gettext("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.")
221            } else {
222                gettext("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.")
223            };
224            self.success_description.set_label(&description);
225
226            if let Some(key) = key {
227                self.success_key_label.set_label(&key);
228            }
229            self.success_key_box.set_visible(has_key);
230        }
231
232        /// Focus the proper widget for the current page.
233        #[template_callback]
234        fn grab_focus(&self) {
235            <Self as WidgetImpl>::grab_focus(self);
236        }
237
238        /// The content of the recover entry changed.
239        #[template_callback]
240        fn recover_entry_changed(&self) {
241            let can_recover = !self.recover_entry.text().is_empty();
242            self.recover_btn.set_sensitive(can_recover);
243        }
244
245        /// Recover the data.
246        #[template_callback]
247        async fn recover(&self) {
248            let Some(session) = self.session.upgrade() else {
249                return;
250            };
251
252            let key = self.recover_entry.text();
253
254            if key.is_empty() {
255                return;
256            }
257
258            self.recover_btn.set_is_loading(true);
259
260            let encryption = session.client().encryption();
261            let recovery = encryption.recovery();
262            let handle = spawn_tokio!(async move { recovery.recover(&key).await });
263
264            match handle.await.unwrap() {
265                Ok(()) => {
266                    // Even if recovery was successful, the recovery data may not have been
267                    // complete. Because the SDK uses multiple threads, we are only
268                    // sure of the SDK's recovery state at this point, not the Session's.
269                    if encryption.recovery().state() == SdkRecoveryState::Incomplete {
270                        self.navigation
271                            .push_by_tag(CryptoRecoverySetupPage::Incomplete.as_ref());
272                    } else {
273                        self.emit_completed();
274                    }
275                }
276                Err(error) => {
277                    error!("Could not recover account: {error}");
278                    let obj = self.obj();
279
280                    match error {
281                        RecoveryError::SecretStorage(SecretStorageError::SecretStorageKey(_)) => {
282                            toast!(obj, gettext("The recovery passphrase or key is invalid"));
283                        }
284                        _ => {
285                            toast!(obj, gettext("Could not access recovery data"));
286                        }
287                    }
288                }
289            }
290
291            self.recover_btn.set_is_loading(false);
292        }
293
294        /// Reset recovery and optionally cross-signing and room keys backup.
295        #[template_callback]
296        async fn reset(&self) {
297            self.reset_btn.set_is_loading(true);
298
299            let reset_identity = self.reset_identity_row.is_active();
300            if reset_identity && self.reset_cross_signing().await.is_err() {
301                self.reset_btn.set_is_loading(false);
302                return;
303            }
304
305            let passphrase = self.reset_entry.text();
306
307            let reset_backup = self.reset_backup_row.is_active();
308            if reset_backup {
309                self.reset_backup_and_recovery(passphrase).await;
310            } else {
311                self.reset_recovery(passphrase).await;
312            }
313
314            self.reset_btn.set_is_loading(false);
315        }
316
317        /// Reset the cross-signing identity.
318        async fn reset_cross_signing(&self) -> Result<(), ()> {
319            let Some(session) = self.session.upgrade() else {
320                return Err(());
321            };
322
323            let dialog = AuthDialog::new(&session);
324            let obj = self.obj();
325
326            let result = dialog.reset_cross_signing(&*obj).await;
327
328            match result {
329                Ok(()) => Ok(()),
330                Err(AuthError::UserCancelled) => {
331                    debug!("User cancelled authentication for cross-signing bootstrap");
332                    Err(())
333                }
334                Err(error) => {
335                    error!("Could not bootstrap cross-signing: {error}");
336                    toast!(obj, gettext("Could not reset the crypto identity"));
337                    Err(())
338                }
339            }
340        }
341
342        /// Reset the room keys backup and the account recovery key.
343        async fn reset_backup_and_recovery(&self, passphrase: glib::GString) {
344            let Some(session) = self.session.upgrade() else {
345                return;
346            };
347
348            let passphrase = Some(passphrase).filter(|s| !s.is_empty());
349            let has_passphrase = passphrase.is_some();
350
351            let obj = self.obj();
352            let encryption = session.client().encryption();
353
354            // There is no method to reset the room keys backup, so we need to disable
355            // recovery and re-enable it.
356            // If backups are not enabled locally, we cannot disable recovery, the API will
357            // return an error. If a backup exists on the homeserver but backups are not
358            // enabled locally, we need to delete the backup manually.
359            // In any case, `Recovery::enable` will reset the secret storage.
360            let backups = encryption.backups();
361            let (backups_are_enabled, backup_exists_on_server) = spawn_tokio!(async move {
362            let backups_are_enabled = backups.are_enabled().await;
363
364            let backup_exists_on_server = if backups_are_enabled {
365                true
366            } else {
367                // Let's use up-to-date data instead of relying on the last time that we updated it.
368                match backups.exists_on_server().await {
369                    Ok(exists) => exists,
370                    Err(error) => {
371                        warn!("Could not request whether recovery backup exists on homeserver: {error}");
372                        // If the request failed, we have to try to delete the backup to avoid unsolvable errors.
373                        true
374                    }
375                }
376            };
377
378            (backups_are_enabled, backup_exists_on_server)
379        })
380        .await
381        .expect("task was not aborted");
382
383            if !backups_are_enabled && backup_exists_on_server {
384                let backups = encryption.backups();
385                let handle = spawn_tokio!(async move { backups.disable_and_delete().await });
386
387                if let Err(error) = handle.await.expect("task was not aborted") {
388                    error!("Could not disable backups: {error}");
389                    toast!(obj, gettext("Could not reset account recovery"));
390                    return;
391                }
392            } else if backups_are_enabled {
393                let recovery = encryption.recovery();
394                let handle = spawn_tokio!(async move { recovery.disable().await });
395
396                if let Err(error) = handle.await.expect("task was not aborted") {
397                    error!("Could not disable recovery: {error}");
398                    toast!(obj, gettext("Could not reset account recovery"));
399                    return;
400                }
401            }
402
403            let recovery = encryption.recovery();
404            let handle = spawn_tokio!(async move {
405                let mut enable = recovery.enable();
406                if let Some(passphrase) = passphrase.as_deref() {
407                    enable = enable.with_passphrase(passphrase);
408                }
409
410                enable.await
411            });
412
413            match handle.await.unwrap() {
414                Ok(key) => {
415                    let key = (!has_passphrase).then_some(key);
416
417                    self.update_success(key);
418                    self.navigation
419                        .push_by_tag(CryptoRecoverySetupPage::Success.as_ref());
420                }
421                Err(error) => {
422                    error!("Could not re-enable account recovery: {error}");
423                    toast!(obj, gettext("Could not reset account recovery"));
424                }
425            }
426        }
427
428        /// Reset the account recovery key.
429        async fn reset_recovery(&self, passphrase: glib::GString) {
430            let Some(session) = self.session.upgrade() else {
431                return;
432            };
433
434            let passphrase = Some(passphrase).filter(|s| !s.is_empty());
435            let has_passphrase = passphrase.is_some();
436
437            let recovery = session.client().encryption().recovery();
438            let handle = spawn_tokio!(async move {
439                let mut reset = recovery.reset_key();
440                if let Some(passphrase) = passphrase.as_deref() {
441                    reset = reset.with_passphrase(passphrase);
442                }
443
444                reset.await
445            });
446
447            match handle.await.unwrap() {
448                Ok(key) => {
449                    let key = (!has_passphrase).then_some(key);
450
451                    self.update_success(key);
452                    self.navigation
453                        .push_by_tag(CryptoRecoverySetupPage::Success.as_ref());
454                }
455                Err(error) => {
456                    error!("Could not reset account recovery key: {error}");
457                    let obj = self.obj();
458                    toast!(obj, gettext("Could not reset account recovery key"));
459                }
460            }
461        }
462
463        /// Enable recovery.
464        #[template_callback]
465        async fn enable(&self) {
466            let Some(session) = self.session.upgrade() else {
467                return;
468            };
469
470            self.enable_btn.set_is_loading(true);
471
472            let passphrase = Some(self.enable_entry.text()).filter(|s| !s.is_empty());
473            let has_passphrase = passphrase.is_some();
474
475            let recovery = session.client().encryption().recovery();
476            let handle = spawn_tokio!(async move {
477                let mut enable = recovery.enable();
478                if let Some(passphrase) = passphrase.as_deref() {
479                    enable = enable.with_passphrase(passphrase);
480                }
481
482                enable.await
483            });
484
485            match handle.await.unwrap() {
486                Ok(key) => {
487                    let key = if has_passphrase { None } else { Some(key) };
488
489                    self.update_success(key);
490                    self.navigation
491                        .push_by_tag(CryptoRecoverySetupPage::Success.as_ref());
492                }
493                Err(error) => {
494                    error!("Could not enable account recovery: {error}");
495                    let obj = self.obj();
496                    toast!(obj, gettext("Could not enable account recovery"));
497                }
498            }
499
500            self.enable_btn.set_is_loading(false);
501        }
502
503        /// Copy the recovery key to the clipboard.
504        #[template_callback]
505        fn copy_key(&self) {
506            let obj = self.obj();
507            let key = self.success_key_label.label();
508
509            let clipboard = obj.clipboard();
510            clipboard.set_text(&key);
511
512            toast!(obj, "Recovery key copied to clipboard");
513        }
514
515        // Emit the `completed` signal.
516        #[template_callback]
517        fn emit_completed(&self) {
518            self.obj().emit_by_name::<()>("completed", &[]);
519        }
520
521        // Show the reset page, after updating it.
522        #[template_callback]
523        fn show_reset(&self) {
524            self.update_reset();
525            self.navigation
526                .push_by_tag(CryptoRecoverySetupPage::Reset.as_ref());
527        }
528    }
529}
530
531glib::wrapper! {
532    /// A view with the different flows to use or set up account recovery.
533    pub struct CryptoRecoverySetupView(ObjectSubclass<imp::CryptoRecoverySetupView>)
534        @extends gtk::Widget, adw::Bin, @implements gtk::Accessible;
535}
536
537impl CryptoRecoverySetupView {
538    pub fn new(session: &Session) -> Self {
539        glib::Object::builder().property("session", session).build()
540    }
541
542    /// Set the initial page of this view.
543    pub(crate) fn set_initial_page(&self, initial_page: CryptoRecoverySetupInitialPage) {
544        self.imp().set_initial_page(initial_page);
545    }
546
547    /// Connect to the signal emitted when the recovery was successfully
548    /// enabled.
549    pub fn connect_completed<F: Fn(&Self) + 'static>(&self, f: F) -> glib::SignalHandlerId {
550        self.connect_closure(
551            "completed",
552            true,
553            closure_local!(move |obj: Self| {
554                f(&obj);
555            }),
556        )
557    }
558}