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