fractal/components/crypto/
recovery_setup_view.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::EnumString, strum::AsRefStr)]
18#[strum(serialize_all = "kebab-case")]
19enum CryptoRecoverySetupPage {
20 Recover,
22 Reset,
24 Enable,
26 Success,
28 Incomplete,
30}
31
32#[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 #[default]
39 Recover,
40 Reset,
42 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 #[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 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 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 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 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 pub(super) fn set_initial_page(&self, initial_page: CryptoRecoverySetupInitialPage) {
212 self.navigation.replace_with_tags(&[initial_page.as_ref()]);
213 }
214
215 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 #[template_callback]
234 fn grab_focus(&self) {
235 <Self as WidgetImpl>::grab_focus(self);
236 }
237
238 #[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 #[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 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 #[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 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 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 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 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 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 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 #[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 #[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 #[template_callback]
517 fn emit_completed(&self) {
518 self.obj().emit_by_name::<()>("completed", &[]);
519 }
520
521 #[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 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 pub(crate) fn set_initial_page(&self, initial_page: CryptoRecoverySetupInitialPage) {
544 self.imp().set_initial_page(initial_page);
545 }
546
547 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}