fractal/components/crypto/
recovery_setup_view.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18enum CryptoRecoverySetupPage {
19 Recover,
21 Reset,
23 Enable,
25 Success,
27 Incomplete,
29}
30
31impl CryptoRecoverySetupPage {
32 const fn tag(self) -> &'static str {
34 match self {
35 Self::Recover => "recover",
36 Self::Reset => "reset",
37 Self::Enable => "enable",
38 Self::Success => "success",
39 Self::Incomplete => "incomplete",
40 }
41 }
42
43 fn from_tag(tag: &str) -> Self {
47 match tag {
48 "recover" => Self::Recover,
49 "reset" => Self::Reset,
50 "enable" => Self::Enable,
51 "success" => Self::Success,
52 "incomplete" => Self::Incomplete,
53 _ => panic!("Unknown CryptoRecoverySetupPage: {tag}"),
54 }
55 }
56}
57
58#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
60pub enum CryptoRecoverySetupInitialPage {
61 #[default]
63 Recover,
64 Reset,
66 Enable,
68}
69
70impl From<CryptoRecoverySetupInitialPage> for CryptoRecoverySetupPage {
71 fn from(value: CryptoRecoverySetupInitialPage) -> Self {
72 match value {
73 CryptoRecoverySetupInitialPage::Recover => Self::Recover,
74 CryptoRecoverySetupInitialPage::Reset => Self::Reset,
75 CryptoRecoverySetupInitialPage::Enable => Self::Enable,
76 }
77 }
78}
79
80mod imp {
81 use std::sync::LazyLock;
82
83 use glib::subclass::{InitializingObject, Signal};
84
85 use super::*;
86
87 #[derive(Debug, Default, gtk::CompositeTemplate, glib::Properties)]
88 #[template(resource = "/org/gnome/Fractal/ui/components/crypto/recovery_setup_view.ui")]
89 #[properties(wrapper_type = super::CryptoRecoverySetupView)]
90 pub struct CryptoRecoverySetupView {
91 #[template_child]
92 navigation: TemplateChild<adw::NavigationView>,
93 #[template_child]
94 recover_entry: TemplateChild<adw::PasswordEntryRow>,
95 #[template_child]
96 recover_btn: TemplateChild<LoadingButton>,
97 #[template_child]
98 reset_page: TemplateChild<adw::NavigationPage>,
99 #[template_child]
100 reset_identity_row: TemplateChild<SwitchLoadingRow>,
101 #[template_child]
102 reset_backup_row: TemplateChild<SwitchLoadingRow>,
103 #[template_child]
104 reset_entry: TemplateChild<adw::PasswordEntryRow>,
105 #[template_child]
106 reset_btn: TemplateChild<LoadingButton>,
107 #[template_child]
108 enable_entry: TemplateChild<adw::PasswordEntryRow>,
109 #[template_child]
110 enable_btn: TemplateChild<LoadingButton>,
111 #[template_child]
112 success_description: TemplateChild<gtk::Label>,
113 #[template_child]
114 success_key_box: TemplateChild<gtk::Box>,
115 #[template_child]
116 success_key_label: TemplateChild<gtk::Label>,
117 #[template_child]
118 success_key_copy_btn: TemplateChild<gtk::Button>,
119 #[template_child]
120 success_confirm_btn: TemplateChild<gtk::Button>,
121 #[template_child]
122 incomplete_confirm_btn: TemplateChild<gtk::Button>,
123 #[property(get, set = Self::set_session, construct_only)]
125 session: glib::WeakRef<Session>,
126 }
127
128 #[glib::object_subclass]
129 impl ObjectSubclass for CryptoRecoverySetupView {
130 const NAME: &'static str = "CryptoRecoverySetupView";
131 type Type = super::CryptoRecoverySetupView;
132 type ParentType = adw::Bin;
133
134 fn class_init(klass: &mut Self::Class) {
135 Self::bind_template(klass);
136 Self::bind_template_callbacks(klass);
137
138 klass.set_css_name("setup-view");
139 }
140
141 fn instance_init(obj: &InitializingObject<Self>) {
142 obj.init_template();
143 }
144 }
145
146 #[glib::derived_properties]
147 impl ObjectImpl for CryptoRecoverySetupView {
148 fn signals() -> &'static [Signal] {
149 static SIGNALS: LazyLock<Vec<Signal>> = LazyLock::new(|| {
150 vec![
151 Signal::builder("completed").build(),
153 ]
154 });
155 SIGNALS.as_ref()
156 }
157 }
158
159 impl WidgetImpl for CryptoRecoverySetupView {
160 fn grab_focus(&self) -> bool {
161 match self.visible_page() {
162 CryptoRecoverySetupPage::Recover => self.recover_entry.grab_focus(),
163 CryptoRecoverySetupPage::Reset => self.reset_entry.grab_focus(),
164 CryptoRecoverySetupPage::Enable => self.enable_entry.grab_focus(),
165 CryptoRecoverySetupPage::Success => self.success_confirm_btn.grab_focus(),
166 CryptoRecoverySetupPage::Incomplete => self.incomplete_confirm_btn.grab_focus(),
167 }
168 }
169 }
170
171 impl BinImpl for CryptoRecoverySetupView {}
172
173 #[gtk::template_callbacks]
174 impl CryptoRecoverySetupView {
175 fn visible_page(&self) -> CryptoRecoverySetupPage {
177 CryptoRecoverySetupPage::from_tag(
178 &self
179 .navigation
180 .visible_page()
181 .expect(
182 "CryptoRecoverySetupView navigation view should always have a visible page",
183 )
184 .tag()
185 .expect("CryptoRecoverySetupView navigation page should always have a tag"),
186 )
187 }
188
189 fn set_session(&self, session: &Session) {
191 self.session.set(Some(session));
192
193 let security = session.security();
194 let recovery_state = security.recovery_state();
195 let initial_page = match recovery_state {
196 RecoveryState::Unknown | RecoveryState::Disabled
197 if !security.backup_exists_on_server() =>
198 {
199 CryptoRecoverySetupInitialPage::Enable
200 }
201 RecoveryState::Unknown | RecoveryState::Disabled | RecoveryState::Enabled => {
202 CryptoRecoverySetupInitialPage::Reset
203 }
204 RecoveryState::Incomplete => CryptoRecoverySetupInitialPage::Recover,
205 };
206
207 self.update_reset();
208 self.set_initial_page(initial_page);
209 }
210
211 fn update_reset(&self) {
213 let Some(session) = self.session.upgrade() else {
214 return;
215 };
216
217 let security = session.security();
218 let (required, description) = if security.cross_signing_keys_available() {
219 (
220 false,
221 gettext("Invalidates the verifications of all users and sessions"),
222 )
223 } else {
224 (
225 true,
226 gettext(
227 "Required because the crypto identity in the recovery data is incomplete. Invalidates the verifications of all users and sessions.",
228 ),
229 )
230 };
231 self.reset_identity_row.set_read_only(required);
232 self.reset_identity_row.set_is_active(required);
233 self.reset_identity_row.set_subtitle(&description);
234
235 let (required, description) = if security.backup_enabled() {
236 (
237 false,
238 gettext("You might not be able to read your past encrypted messages anymore"),
239 )
240 } else {
241 (
242 true,
243 gettext(
244 "Required because the backup is not set up properly. You might not be able to read your past encrypted messages anymore.",
245 ),
246 )
247 };
248 self.reset_backup_row.set_read_only(required);
249 self.reset_backup_row.set_is_active(required);
250 self.reset_backup_row.set_subtitle(&description);
251 }
252
253 pub(super) fn set_initial_page(&self, initial_page: CryptoRecoverySetupInitialPage) {
255 self.navigation
256 .replace_with_tags(&[CryptoRecoverySetupPage::from(initial_page).tag()]);
257 }
258
259 fn update_success(&self, key: Option<String>) {
261 let has_key = key.is_some();
262
263 let description = if has_key {
264 gettext(
265 "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.",
266 )
267 } else {
268 gettext(
269 "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.",
270 )
271 };
272 self.success_description.set_label(&description);
273
274 if let Some(key) = key {
275 self.success_key_label.set_label(&key);
276 }
277 self.success_key_box.set_visible(has_key);
278 }
279
280 #[template_callback]
282 fn grab_focus(&self) {
283 <Self as WidgetImpl>::grab_focus(self);
284 }
285
286 #[template_callback]
288 fn recover_entry_changed(&self) {
289 let can_recover = !self.recover_entry.text().is_empty();
290 self.recover_btn.set_sensitive(can_recover);
291 }
292
293 #[template_callback]
295 async fn recover(&self) {
296 let Some(session) = self.session.upgrade() else {
297 return;
298 };
299
300 let key = self.recover_entry.text();
301
302 if key.is_empty() {
303 return;
304 }
305
306 self.recover_btn.set_is_loading(true);
307
308 let encryption = session.client().encryption();
309 let recovery = encryption.recovery();
310 let handle = spawn_tokio!(async move { recovery.recover(&key).await });
311
312 match handle.await.unwrap() {
313 Ok(()) => {
314 if encryption.recovery().state() == SdkRecoveryState::Incomplete {
318 self.navigation
319 .push_by_tag(CryptoRecoverySetupPage::Incomplete.tag());
320 } else {
321 self.emit_completed();
322 }
323 }
324 Err(error) => {
325 error!("Could not recover account: {error}");
326 let obj = self.obj();
327
328 match error {
329 RecoveryError::SecretStorage(SecretStorageError::SecretStorageKey(_)) => {
330 toast!(obj, gettext("The recovery passphrase or key is invalid"));
331 }
332 _ => {
333 toast!(obj, gettext("Could not access recovery data"));
334 }
335 }
336 }
337 }
338
339 self.recover_btn.set_is_loading(false);
340 }
341
342 #[template_callback]
344 async fn reset(&self) {
345 self.reset_btn.set_is_loading(true);
346
347 let reset_identity = self.reset_identity_row.is_active();
348 if reset_identity && self.reset_cross_signing().await.is_err() {
349 self.reset_btn.set_is_loading(false);
350 return;
351 }
352
353 let passphrase = self.reset_entry.text();
354
355 let reset_backup = self.reset_backup_row.is_active();
356 if reset_backup {
357 self.reset_backup_and_recovery(passphrase).await;
358 } else {
359 self.reset_recovery(passphrase).await;
360 }
361
362 self.reset_btn.set_is_loading(false);
363 }
364
365 async fn reset_cross_signing(&self) -> Result<(), ()> {
367 let Some(session) = self.session.upgrade() else {
368 return Err(());
369 };
370
371 let dialog = AuthDialog::new(&session);
372 let obj = self.obj();
373
374 let result = dialog.reset_cross_signing(&*obj).await;
375
376 match result {
377 Ok(()) => Ok(()),
378 Err(AuthError::UserCancelled) => {
379 debug!("User cancelled authentication for cross-signing bootstrap");
380 Err(())
381 }
382 Err(error) => {
383 error!("Could not bootstrap cross-signing: {error}");
384 toast!(obj, gettext("Could not reset the crypto identity"));
385 Err(())
386 }
387 }
388 }
389
390 async fn reset_backup_and_recovery(&self, passphrase: glib::GString) {
392 let Some(session) = self.session.upgrade() else {
393 return;
394 };
395
396 let passphrase = Some(passphrase).filter(|s| !s.is_empty());
397 let has_passphrase = passphrase.is_some();
398
399 let obj = self.obj();
400 let encryption = session.client().encryption();
401
402 let backups = encryption.backups();
409 let (backups_are_enabled, backup_exists_on_server) = spawn_tokio!(async move {
410 let backups_are_enabled = backups.are_enabled().await;
411
412 let backup_exists_on_server = if backups_are_enabled {
413 true
414 } else {
415 match backups.exists_on_server().await {
417 Ok(exists) => exists,
418 Err(error) => {
419 warn!("Could not request whether recovery backup exists on homeserver: {error}");
420 true
422 }
423 }
424 };
425
426 (backups_are_enabled, backup_exists_on_server)
427 })
428 .await
429 .expect("task was not aborted");
430
431 if !backups_are_enabled && backup_exists_on_server {
432 let backups = encryption.backups();
433 let handle = spawn_tokio!(async move { backups.disable_and_delete().await });
434
435 if let Err(error) = handle.await.expect("task was not aborted") {
436 error!("Could not disable backups: {error}");
437 toast!(obj, gettext("Could not reset account recovery"));
438 return;
439 }
440 } else if backups_are_enabled {
441 let recovery = encryption.recovery();
442 let handle = spawn_tokio!(async move { recovery.disable().await });
443
444 if let Err(error) = handle.await.expect("task was not aborted") {
445 error!("Could not disable recovery: {error}");
446 toast!(obj, gettext("Could not reset account recovery"));
447 return;
448 }
449 }
450
451 let recovery = encryption.recovery();
452 let handle = spawn_tokio!(async move {
453 let mut enable = recovery.enable();
454 if let Some(passphrase) = passphrase.as_deref() {
455 enable = enable.with_passphrase(passphrase);
456 }
457
458 enable.await
459 });
460
461 match handle.await.unwrap() {
462 Ok(key) => {
463 let key = (!has_passphrase).then_some(key);
464
465 self.update_success(key);
466 self.navigation
467 .push_by_tag(CryptoRecoverySetupPage::Success.tag());
468 }
469 Err(error) => {
470 error!("Could not re-enable account recovery: {error}");
471 toast!(obj, gettext("Could not reset account recovery"));
472 }
473 }
474 }
475
476 async fn reset_recovery(&self, passphrase: glib::GString) {
478 let Some(session) = self.session.upgrade() else {
479 return;
480 };
481
482 let passphrase = Some(passphrase).filter(|s| !s.is_empty());
483 let has_passphrase = passphrase.is_some();
484
485 let recovery = session.client().encryption().recovery();
486 let handle = spawn_tokio!(async move {
487 let mut reset = recovery.reset_key();
488 if let Some(passphrase) = passphrase.as_deref() {
489 reset = reset.with_passphrase(passphrase);
490 }
491
492 reset.await
493 });
494
495 match handle.await.unwrap() {
496 Ok(key) => {
497 let key = (!has_passphrase).then_some(key);
498
499 self.update_success(key);
500 self.navigation
501 .push_by_tag(CryptoRecoverySetupPage::Success.tag());
502 }
503 Err(error) => {
504 error!("Could not reset account recovery key: {error}");
505 let obj = self.obj();
506 toast!(obj, gettext("Could not reset account recovery key"));
507 }
508 }
509 }
510
511 #[template_callback]
513 async fn enable(&self) {
514 let Some(session) = self.session.upgrade() else {
515 return;
516 };
517
518 self.enable_btn.set_is_loading(true);
519
520 let passphrase = Some(self.enable_entry.text()).filter(|s| !s.is_empty());
521 let has_passphrase = passphrase.is_some();
522
523 let recovery = session.client().encryption().recovery();
524 let handle = spawn_tokio!(async move {
525 let mut enable = recovery.enable();
526 if let Some(passphrase) = passphrase.as_deref() {
527 enable = enable.with_passphrase(passphrase);
528 }
529
530 enable.await
531 });
532
533 match handle.await.unwrap() {
534 Ok(key) => {
535 let key = if has_passphrase { None } else { Some(key) };
536
537 self.update_success(key);
538 self.navigation
539 .push_by_tag(CryptoRecoverySetupPage::Success.tag());
540 }
541 Err(error) => {
542 error!("Could not enable account recovery: {error}");
543 let obj = self.obj();
544 toast!(obj, gettext("Could not enable account recovery"));
545 }
546 }
547
548 self.enable_btn.set_is_loading(false);
549 }
550
551 #[template_callback]
553 fn copy_key(&self) {
554 let obj = self.obj();
555 let key = self.success_key_label.label();
556
557 let clipboard = obj.clipboard();
558 clipboard.set_text(&key);
559
560 toast!(obj, "Recovery key copied to clipboard");
561 }
562
563 #[template_callback]
565 fn emit_completed(&self) {
566 self.obj().emit_by_name::<()>("completed", &[]);
567 }
568
569 #[template_callback]
571 fn show_reset(&self) {
572 self.update_reset();
573 self.navigation
574 .push_by_tag(CryptoRecoverySetupPage::Reset.tag());
575 }
576 }
577}
578
579glib::wrapper! {
580 pub struct CryptoRecoverySetupView(ObjectSubclass<imp::CryptoRecoverySetupView>)
582 @extends gtk::Widget, adw::Bin,
583 @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget;
584}
585
586impl CryptoRecoverySetupView {
587 pub fn new(session: &Session) -> Self {
588 glib::Object::builder().property("session", session).build()
589 }
590
591 pub(crate) fn set_initial_page(&self, initial_page: CryptoRecoverySetupInitialPage) {
593 self.imp().set_initial_page(initial_page);
594 }
595
596 pub fn connect_completed<F: Fn(&Self) + 'static>(&self, f: F) -> glib::SignalHandlerId {
599 self.connect_closure(
600 "completed",
601 true,
602 closure_local!(move |obj: Self| {
603 f(&obj);
604 }),
605 )
606 }
607}