Skip to main content

fractal/components/crypto/
identity_setup_view.rs

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