fractal/components/crypto/
identity_setup_view.rs

1use adw::{prelude::*, subclass::prelude::*};
2use gettextrs::gettext;
3use gtk::{
4    CompositeTemplate, glib,
5    glib::{clone, closure_local},
6};
7use tracing::{debug, error};
8
9use super::{CryptoRecoverySetupInitialPage, CryptoRecoverySetupView};
10use crate::{
11    components::{AuthDialog, AuthError, LoadingButton},
12    identity_verification_view::IdentityVerificationView,
13    session::model::{
14        CryptoIdentityState, IdentityVerification, RecoveryState, Session, SessionVerificationState,
15    },
16    spawn, toast,
17    utils::BoundObjectWeakRef,
18};
19
20/// A page of the crypto identity setup navigation stack.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::EnumString, strum::AsRefStr, glib::Variant)]
22#[strum(serialize_all = "kebab-case")]
23enum CryptoIdentitySetupPage {
24    /// Choose a verification method.
25    ChooseMethod,
26    /// In-progress verification.
27    Verify,
28    /// Bootstrap cross-signing.
29    Bootstrap,
30    /// Reset cross-signing.
31    Reset,
32    /// Use recovery or reset cross-signing and recovery.
33    Recovery,
34}
35
36/// The result of the crypto identity setup.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, glib::Enum)]
38#[enum_type(name = "CryptoIdentitySetupNextStep")]
39pub enum CryptoIdentitySetupNextStep {
40    /// No more steps should be needed.
41    None,
42    /// We should enable the recovery, if it is disabled.
43    EnableRecovery,
44    /// We should make sure that the recovery is fully set up.
45    CompleteRecovery,
46}
47
48mod imp {
49    use std::{
50        cell::{OnceCell, RefCell},
51        sync::LazyLock,
52    };
53
54    use glib::subclass::{InitializingObject, Signal};
55
56    use super::*;
57
58    #[derive(Debug, Default, CompositeTemplate, glib::Properties)]
59    #[template(resource = "/org/gnome/Fractal/ui/components/crypto/identity_setup_view.ui")]
60    #[properties(wrapper_type = super::CryptoIdentitySetupView)]
61    pub struct CryptoIdentitySetupView {
62        #[template_child]
63        navigation: TemplateChild<adw::NavigationView>,
64        #[template_child]
65        send_request_btn: TemplateChild<LoadingButton>,
66        #[template_child]
67        use_recovery_btn: TemplateChild<gtk::Button>,
68        #[template_child]
69        verification_page: TemplateChild<IdentityVerificationView>,
70        #[template_child]
71        bootstrap_btn: TemplateChild<LoadingButton>,
72        #[template_child]
73        reset_btn: TemplateChild<gtk::Button>,
74        /// The current session.
75        #[property(get, set = Self::set_session, construct_only)]
76        session: glib::WeakRef<Session>,
77        /// The ongoing identity verification, if any.
78        #[property(get)]
79        verification: BoundObjectWeakRef<IdentityVerification>,
80        verification_list_handler: RefCell<Option<glib::SignalHandlerId>>,
81        /// The recovery view.
82        recovery_view: OnceCell<CryptoRecoverySetupView>,
83    }
84
85    #[glib::object_subclass]
86    impl ObjectSubclass for CryptoIdentitySetupView {
87        const NAME: &'static str = "CryptoIdentitySetupView";
88        type Type = super::CryptoIdentitySetupView;
89        type ParentType = adw::Bin;
90
91        fn class_init(klass: &mut Self::Class) {
92            Self::bind_template(klass);
93            Self::bind_template_callbacks(klass);
94
95            klass.set_css_name("setup-view");
96        }
97
98        fn instance_init(obj: &InitializingObject<Self>) {
99            obj.init_template();
100        }
101    }
102
103    #[glib::derived_properties]
104    impl ObjectImpl for CryptoIdentitySetupView {
105        fn signals() -> &'static [Signal] {
106            static SIGNALS: LazyLock<Vec<Signal>> = LazyLock::new(|| {
107                vec![
108                    // The crypto identity setup is done.
109                    Signal::builder("completed")
110                        .param_types([CryptoIdentitySetupNextStep::static_type()])
111                        .build(),
112                ]
113            });
114            SIGNALS.as_ref()
115        }
116
117        fn dispose(&self) {
118            if let Some(verification) = self.verification.obj() {
119                spawn!(clone!(
120                    #[strong]
121                    verification,
122                    async move {
123                        let _ = verification.cancel().await;
124                    }
125                ));
126            }
127
128            if let Some(session) = self.session.upgrade() {
129                if let Some(handler) = self.verification_list_handler.take() {
130                    session.verification_list().disconnect(handler);
131                }
132            }
133        }
134    }
135
136    impl WidgetImpl for CryptoIdentitySetupView {
137        fn grab_focus(&self) -> bool {
138            match self.visible_page() {
139                CryptoIdentitySetupPage::ChooseMethod => self.send_request_btn.grab_focus(),
140                CryptoIdentitySetupPage::Verify => self.verification_page.grab_focus(),
141                CryptoIdentitySetupPage::Bootstrap => self.bootstrap_btn.grab_focus(),
142                CryptoIdentitySetupPage::Reset => self.reset_btn.grab_focus(),
143                CryptoIdentitySetupPage::Recovery => self.recovery_view().grab_focus(),
144            }
145        }
146    }
147
148    impl BinImpl for CryptoIdentitySetupView {}
149
150    #[gtk::template_callbacks]
151    impl CryptoIdentitySetupView {
152        /// The visible page of the view.
153        fn visible_page(&self) -> CryptoIdentitySetupPage {
154            self.navigation
155                .visible_page()
156                .and_then(|p| p.tag())
157                .and_then(|t| t.as_str().try_into().ok())
158                .unwrap()
159        }
160
161        /// The recovery view.
162        fn recovery_view(&self) -> &CryptoRecoverySetupView {
163            self.recovery_view.get_or_init(|| {
164                let session = self
165                    .session
166                    .upgrade()
167                    .expect("Session should still have a strong reference");
168                let recovery_view = CryptoRecoverySetupView::new(&session);
169
170                recovery_view.connect_completed(clone!(
171                    #[weak(rename_to = imp)]
172                    self,
173                    move |_| {
174                        imp.emit_completed(CryptoIdentitySetupNextStep::None);
175                    }
176                ));
177
178                recovery_view
179            })
180        }
181
182        /// Set the current session.
183        fn set_session(&self, session: &Session) {
184            self.session.set(Some(session));
185
186            // Use received verification requests too.
187            let verification_list = session.verification_list();
188            let verification_list_handler = verification_list.connect_items_changed(clone!(
189                #[weak(rename_to = imp)]
190                self,
191                move |verification_list, _, _, _| {
192                    if imp.verification.obj().is_some() {
193                        // We don't want to override the current verification.
194                        return;
195                    }
196
197                    if let Some(verification) = verification_list.ongoing_session_verification() {
198                        imp.set_verification(Some(verification));
199                    }
200                }
201            ));
202            self.verification_list_handler
203                .replace(Some(verification_list_handler));
204
205            self.init();
206        }
207
208        /// Initialize the view.
209        fn init(&self) {
210            let Some(session) = self.session.upgrade() else {
211                return;
212            };
213            let security = session.security();
214
215            // If the session is already verified, offer to reset it.
216            let verification_state = security.verification_state();
217            if verification_state == SessionVerificationState::Verified {
218                self.navigation
219                    .replace_with_tags(&[CryptoIdentitySetupPage::Reset.as_ref()]);
220                return;
221            }
222
223            let crypto_identity_state = security.crypto_identity_state();
224            let recovery_state = security.recovery_state();
225
226            // If there is no crypto identity, we need to bootstrap it.
227            if crypto_identity_state == CryptoIdentityState::Missing {
228                self.navigation
229                    .replace_with_tags(&[CryptoIdentitySetupPage::Bootstrap.as_ref()]);
230                return;
231            }
232
233            // If there is no other session available, we can only use recovery or reset.
234            if crypto_identity_state == CryptoIdentityState::LastManStanding {
235                let recovery_view = if recovery_state == RecoveryState::Disabled {
236                    // If recovery is disabled, we can only reset.
237                    self.recovery_page(CryptoRecoverySetupInitialPage::Reset)
238                } else {
239                    // We can recover or reset.
240                    self.recovery_page(CryptoRecoverySetupInitialPage::Recover)
241                };
242
243                self.navigation.replace(&[recovery_view]);
244
245                return;
246            }
247
248            if let Some(verification) = session.verification_list().ongoing_session_verification() {
249                self.set_verification(Some(verification));
250            }
251
252            // Choose methods is the default page.
253            self.update_choose_methods();
254        }
255
256        /// Update the choose methods page for the current state.
257        fn update_choose_methods(&self) {
258            let Some(session) = self.session.upgrade() else {
259                return;
260            };
261
262            let can_recover = session.security().recovery_state() != RecoveryState::Disabled;
263            self.use_recovery_btn.set_visible(can_recover);
264        }
265
266        /// Set the ongoing identity verification.
267        ///
268        /// Cancels the previous verification if it's not finished.
269        fn set_verification(&self, verification: Option<IdentityVerification>) {
270            let prev_verification = self.verification.obj();
271
272            if prev_verification == verification {
273                return;
274            }
275
276            if let Some(verification) = prev_verification {
277                if !verification.is_finished() {
278                    spawn!(clone!(
279                        #[strong]
280                        verification,
281                        async move {
282                            let _ = verification.cancel().await;
283                        }
284                    ));
285                }
286
287                self.verification.disconnect_signals();
288            }
289
290            if let Some(verification) = &verification {
291                let replaced_handler = verification.connect_replaced(clone!(
292                    #[weak(rename_to = imp)]
293                    self,
294                    move |_, new_verification| {
295                        imp.set_verification(Some(new_verification.clone()));
296                    }
297                ));
298                let done_handler = verification.connect_done(clone!(
299                    #[weak(rename_to = imp)]
300                    self,
301                    #[upgrade_or]
302                    glib::Propagation::Stop,
303                    move |verification| {
304                        imp.emit_completed(CryptoIdentitySetupNextStep::EnableRecovery);
305                        imp.set_verification(None);
306                        verification.remove_from_list();
307
308                        glib::Propagation::Stop
309                    }
310                ));
311                let remove_handler = verification.connect_dismiss(clone!(
312                    #[weak(rename_to = imp)]
313                    self,
314                    move |_| {
315                        imp.navigation.pop();
316                        imp.set_verification(None);
317                    }
318                ));
319
320                self.verification.set(
321                    verification,
322                    vec![replaced_handler, done_handler, remove_handler],
323                );
324            }
325
326            let has_verification = verification.is_some();
327            self.verification_page.set_verification(verification);
328
329            if has_verification
330                && self
331                    .navigation
332                    .visible_page()
333                    .and_then(|p| p.tag())
334                    .is_none_or(|t| t != CryptoIdentitySetupPage::Verify.as_ref())
335            {
336                self.navigation
337                    .push_by_tag(CryptoIdentitySetupPage::Verify.as_ref());
338            }
339
340            self.obj().notify_verification();
341        }
342
343        /// Construct the recovery view and wrap it into a navigation page.
344        fn recovery_page(
345            &self,
346            initial_page: CryptoRecoverySetupInitialPage,
347        ) -> adw::NavigationPage {
348            let recovery_view = self.recovery_view();
349            recovery_view.set_initial_page(initial_page);
350
351            let page = adw::NavigationPage::builder()
352                .tag(CryptoIdentitySetupPage::Recovery.as_ref())
353                .child(recovery_view)
354                .build();
355            page.connect_shown(clone!(
356                #[weak]
357                recovery_view,
358                move |_| {
359                    recovery_view.grab_focus();
360                }
361            ));
362
363            page
364        }
365
366        /// Focus the proper widget for the current page.
367        #[template_callback]
368        fn grab_focus(&self) {
369            <Self as WidgetImpl>::grab_focus(self);
370        }
371
372        /// Create a new verification request.
373        #[template_callback]
374        async fn send_request(&self) {
375            let Some(session) = self.session.upgrade() else {
376                return;
377            };
378
379            self.send_request_btn.set_is_loading(true);
380
381            if let Err(()) = session.verification_list().create(None).await {
382                toast!(
383                    self.obj(),
384                    gettext("Could not send a new verification request")
385                );
386            }
387
388            // On success, the verification should be shown automatically.
389
390            self.send_request_btn.set_is_loading(false);
391        }
392
393        /// Reset cross-signing and optionally recovery.
394        #[template_callback]
395        fn reset(&self) {
396            let Some(session) = self.session.upgrade() else {
397                return;
398            };
399
400            let can_recover = session.security().recovery_state() != RecoveryState::Disabled;
401
402            if can_recover {
403                let recovery_view = self.recovery_page(CryptoRecoverySetupInitialPage::Reset);
404                self.navigation.push(&recovery_view);
405            } else {
406                self.navigation
407                    .push_by_tag(CryptoIdentitySetupPage::Bootstrap.as_ref());
408            }
409        }
410
411        /// Create a new crypto user identity.
412        #[template_callback]
413        async fn bootstrap_cross_signing(&self) {
414            let Some(session) = self.session.upgrade() else {
415                return;
416            };
417
418            self.bootstrap_btn.set_is_loading(true);
419
420            let obj = self.obj();
421            let dialog = AuthDialog::new(&session);
422
423            let result = dialog
424                .authenticate(&*obj, move |client, auth| async move {
425                    client.encryption().bootstrap_cross_signing(auth).await
426                })
427                .await;
428
429            match result {
430                Ok(()) => self.emit_completed(CryptoIdentitySetupNextStep::CompleteRecovery),
431                Err(AuthError::UserCancelled) => {
432                    debug!("User cancelled authentication for cross-signing bootstrap");
433                }
434                Err(error) => {
435                    error!("Could not bootstrap cross-signing: {error:?}");
436                    toast!(obj, gettext("Could not create the crypto identity"));
437                }
438            }
439
440            self.bootstrap_btn.set_is_loading(false);
441        }
442
443        /// Recover the data.
444        #[template_callback]
445        fn recover(&self) {
446            let recovery_view = self.recovery_page(CryptoRecoverySetupInitialPage::Recover);
447            self.navigation.push(&recovery_view);
448        }
449
450        // Emit the `completed` signal.
451        #[template_callback]
452        fn emit_completed(&self, next: CryptoIdentitySetupNextStep) {
453            self.obj().emit_by_name::<()>("completed", &[&next]);
454        }
455    }
456}
457
458glib::wrapper! {
459    /// A view with the different flows to setup a crypto identity.
460    pub struct CryptoIdentitySetupView(ObjectSubclass<imp::CryptoIdentitySetupView>)
461        @extends gtk::Widget, adw::Bin, @implements gtk::Accessible;
462}
463
464impl CryptoIdentitySetupView {
465    pub fn new(session: &Session) -> Self {
466        glib::Object::builder().property("session", session).build()
467    }
468
469    /// Connect to the signal emitted when the setup is completed.
470    pub fn connect_completed<F: Fn(&Self, CryptoIdentitySetupNextStep) + 'static>(
471        &self,
472        f: F,
473    ) -> glib::SignalHandlerId {
474        self.connect_closure(
475            "completed",
476            true,
477            closure_local!(move |obj: Self, next: CryptoIdentitySetupNextStep| {
478                f(&obj, next);
479            }),
480        )
481    }
482}