From 293a76d67faacc371df0149c7e245be61c2965d6 Mon Sep 17 00:00:00 2001 From: Maximiliano Sandoval Date: Wed, 1 Jan 2025 10:04:58 +0100 Subject: [PATCH] Split general and backup settings Into different dialogs. In principle, backup and restore are not app preferences and should go in a different dialog. This commit does not do any changes besides splitting methods into two parts and: - Rename the "preferences" action group to "camera-page" on the backup dialog - Remove content-width on the PreferencesWindow --- data/resources.gresource.xml | 1 + data/resources/ui/backup_dialog.ui | 22 + data/resources/ui/preferences.ui | 17 - data/resources/ui/window.ui | 4 + po/POTFILES.in | 3 +- src/application.rs | 17 +- .../{preferences => backup}/camera_page.rs | 0 src/widgets/backup/dialog.rs | 551 ++++++++++++++++++ src/widgets/backup/mod.rs | 4 + src/widgets/mod.rs | 2 + src/widgets/preferences/mod.rs | 1 - src/widgets/preferences/window.rs | 468 +-------------- 12 files changed, 605 insertions(+), 485 deletions(-) create mode 100644 data/resources/ui/backup_dialog.ui rename src/widgets/{preferences => backup}/camera_page.rs (100%) create mode 100644 src/widgets/backup/dialog.rs create mode 100644 src/widgets/backup/mod.rs diff --git a/data/resources.gresource.xml b/data/resources.gresource.xml index dfd98d0..ac28884 100644 --- a/data/resources.gresource.xml +++ b/data/resources.gresource.xml @@ -16,6 +16,7 @@ resources/ui/providers_list.ui + resources/ui/backup_dialog.ui resources/ui/preferences.ui resources/ui/preferences_camera_page.ui resources/ui/preferences_password_page.ui diff --git a/data/resources/ui/backup_dialog.ui b/data/resources/ui/backup_dialog.ui new file mode 100644 index 0000000..2739d57 --- /dev/null +++ b/data/resources/ui/backup_dialog.ui @@ -0,0 +1,22 @@ + + + + diff --git a/data/resources/ui/preferences.ui b/data/resources/ui/preferences.ui index a2b3e2a..c9b703a 100644 --- a/data/resources/ui/preferences.ui +++ b/data/resources/ui/preferences.ui @@ -7,7 +7,6 @@ 10 diff --git a/data/resources/ui/window.ui b/data/resources/ui/window.ui index 19d6231..ce90f6e 100644 --- a/data/resources/ui/window.ui +++ b/data/resources/ui/window.ui @@ -16,6 +16,10 @@ _Preferences app.preferences + + _Backup & Restore + app.show-backup-dialog +
diff --git a/po/POTFILES.in b/po/POTFILES.in index 322a786..ad20552 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -4,6 +4,7 @@ data/com.belmoussaoui.Authenticator.metainfo.xml.in.in data/resources/ui/account_add.ui data/resources/ui/account_details_page.ui data/resources/ui/account_row.ui +data/resources/ui/backup_dialog.ui data/resources/ui/camera.ui data/resources/ui/keyring_error_dialog.ui data/resources/ui/preferences.ui @@ -28,8 +29,8 @@ src/models/algorithm.rs src/widgets/accounts/add.rs src/widgets/accounts/details.rs src/widgets/accounts/row.rs +src/widgets/backup/dialog.rs src/widgets/preferences/password_page.rs -src/widgets/preferences/window.rs src/widgets/providers/dialog.rs src/widgets/providers/page.rs src/widgets/window.rs diff --git a/src/application.rs b/src/application.rs index 7b0f813..2a96189 100644 --- a/src/application.rs +++ b/src/application.rs @@ -20,7 +20,7 @@ use crate::{ SearchProviderAction, FAVICONS_PATH, RUNTIME, SECRET_SERVICE, SETTINGS, }, utils::{spawn, spawn_tokio_blocking}, - widgets::{KeyringErrorDialog, PreferencesWindow, ProvidersDialog, Window}, + widgets::{BackupDialog, KeyringErrorDialog, PreferencesWindow, ProvidersDialog, Window}, }; mod imp { @@ -66,12 +66,11 @@ mod imp { .activate(|app: &Self::Type, _, _| app.quit()) .build(); - let preferences_action = gio::ActionEntry::builder("preferences") + let show_backup_dialog_action = gio::ActionEntry::builder("show-backup-dialog") .activate(|app: &Self::Type, _, _| { let model = &app.imp().model; let window = app.active_window(); - let preferences = PreferencesWindow::new(model); - preferences.set_has_set_password(app.can_be_locked()); + let preferences = BackupDialog::new(model); preferences.connect_restore_completed(clone!( #[weak] window, @@ -85,6 +84,15 @@ mod imp { ))); } )); + preferences.present(Some(&window)); + }) + .build(); + + let preferences_action = gio::ActionEntry::builder("preferences") + .activate(|app: &Self::Type, _, _| { + let window = app.active_window(); + let preferences = PreferencesWindow::new(); + preferences.set_has_set_password(app.can_be_locked()); preferences.connect_has_set_password_notify(clone!( #[weak] app, @@ -146,6 +154,7 @@ mod imp { lock_action, providers_action, preferences_action, + show_backup_dialog_action, ]); let lock_action = app.lookup_action("lock").unwrap(); diff --git a/src/widgets/preferences/camera_page.rs b/src/widgets/backup/camera_page.rs similarity index 100% rename from src/widgets/preferences/camera_page.rs rename to src/widgets/backup/camera_page.rs diff --git a/src/widgets/backup/dialog.rs b/src/widgets/backup/dialog.rs new file mode 100644 index 0000000..d409329 --- /dev/null +++ b/src/widgets/backup/dialog.rs @@ -0,0 +1,551 @@ +use adw::prelude::*; +use anyhow::Result; +use gettextrs::gettext; +use gtk::{ + gio, + glib::{self, clone}, + subclass::prelude::*, +}; + +use super::camera_page::CameraPage; +use crate::{ + backup::{ + Aegis, AndOTP, Backupable, Bitwarden, FreeOTP, FreeOTPJSON, Google, LegacyAuthenticator, + Operation, RaivoOTP, Restorable, RestorableItem, + }, + models::ProvidersModel, + utils::spawn, + widgets::screenshot, +}; + +mod imp { + use std::{ + cell::{OnceCell, RefCell}, + collections::HashMap, + sync::LazyLock, + }; + + use adw::subclass::prelude::*; + use glib::subclass::Signal; + + use super::*; + + #[derive(gtk::CompositeTemplate, glib::Properties)] + #[properties(wrapper_type = super::BackupDialog)] + #[template(resource = "/com/belmoussaoui/Authenticator/backup_dialog.ui")] + pub struct BackupDialog { + #[property(get, set, construct_only)] + pub model: OnceCell, + + pub actions: gio::SimpleActionGroup, + pub backup_actions: gio::SimpleActionGroup, + pub restore_actions: gio::SimpleActionGroup, + pub camera_page: CameraPage, + pub key_entries: RefCell>, + + #[template_child] + pub backup_group: TemplateChild, + #[template_child] + pub restore_group: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for BackupDialog { + const NAME: &'static str = "BackupDialog"; + type Type = super::BackupDialog; + type ParentType = adw::PreferencesDialog; + + fn new() -> Self { + let actions = gio::SimpleActionGroup::new(); + + Self { + camera_page: CameraPage::new(&actions), + actions, + model: OnceCell::default(), + backup_actions: gio::SimpleActionGroup::new(), + restore_actions: gio::SimpleActionGroup::new(), + backup_group: TemplateChild::default(), + restore_group: TemplateChild::default(), + key_entries: RefCell::default(), + } + } + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(obj: &glib::subclass::InitializingObject) { + obj.init_template(); + } + } + + #[glib::derived_properties] + impl ObjectImpl for BackupDialog { + fn signals() -> &'static [Signal] { + static SIGNALS: LazyLock> = + LazyLock::new(|| vec![Signal::builder("restore-completed").action().build()]); + SIGNALS.as_ref() + } + + fn constructed(&self) { + self.parent_constructed(); + let obj = self.obj(); + + obj.setup_actions(); + obj.setup_widget(); + } + } + impl WidgetImpl for BackupDialog {} + impl AdwDialogImpl for BackupDialog {} + impl PreferencesDialogImpl for BackupDialog {} +} + +glib::wrapper! { + pub struct BackupDialog(ObjectSubclass) + @extends gtk::Widget, adw::Dialog, adw::PreferencesDialog; +} + +impl BackupDialog { + pub fn new(model: &ProvidersModel) -> Self { + glib::Object::builder().property("model", model).build() + } + + pub fn connect_restore_completed(&self, callback: F) -> glib::SignalHandlerId + where + F: Fn(&Self) + 'static, + { + self.connect_local( + "restore-completed", + false, + clone!( + #[weak(rename_to = win)] + self, + #[upgrade_or] + None, + move |_| { + callback(&win); + None + } + ), + ) + } + + fn setup_widget(&self) { + // FreeOTP is first in all of these lists, since its the way to backup + // Authenticator for use with Authenticator. Others are sorted + // alphabetically. + + self.register_backup::(&["text/plain"]); + self.register_backup::(&["application/json"]); + self.register_backup::(&["application/json"]); + + self.register_restore::(&["text/plain"]); + self.register_restore::(&["application/json"]); + self.register_restore::(&["application/json"]); + self.register_restore::(&["application/json"]); + self.register_restore::(&["application/json"]); + self.register_restore::(&[]); + self.register_restore::(&["application/json"]); + self.register_restore::(&["application/zip"]); + } + + fn register_backup(&self, filters: &'static [&str]) { + let imp = self.imp(); + if T::ENCRYPTABLE { + let row = adw::ExpanderRow::builder() + .title(T::title()) + .subtitle(T::subtitle()) + .show_enable_switch(false) + .enable_expansion(true) + .use_underline(true) + .build(); + let key_entry = adw::PasswordEntryRow::builder() + .title(gettext("Key / Passphrase")) + .build(); + row.add_row(&key_entry); + imp.key_entries + .borrow_mut() + .insert(format!("backup.{}", T::IDENTIFIER), key_entry); + + let button_row = adw::ActionRow::new(); + let key_button = gtk::Button::builder() + .valign(gtk::Align::Center) + .halign(gtk::Align::End) + .label(gettext("Select File")) + .action_name(format!("backup.{}", T::IDENTIFIER)) + .build(); + button_row.add_suffix(&key_button); + row.add_row(&button_row); + + imp.backup_group.add(&row); + } else { + let row = adw::ActionRow::builder() + .title(T::title()) + .subtitle(T::subtitle()) + .activatable(true) + .use_underline(true) + .action_name(format!("backup.{}", T::IDENTIFIER)) + .build(); + + imp.backup_group.add(&row); + } + + let action = gio::ActionEntry::builder(T::IDENTIFIER) + .activate(clone!( + #[weak(rename_to = win)] + self, + move |_, _, _| { + spawn(clone!( + #[weak] + win, + async move { + if let Err(err) = win.backup_into_file::(filters).await { + tracing::error!("Failed to backup into a file {err}"); + win.add_toast(adw::Toast::new(&gettext( + "Failed to create a backup", + ))); + } + } + )); + } + )) + .build(); + imp.backup_actions.add_action_entries([action]); + } + + async fn backup_into_file(&self, filters: &'static [&str]) -> Result<()> { + let model = self.model(); + let file = self.select_file(filters, Operation::Backup).await?; + let key = T::ENCRYPTABLE + .then(|| self.encryption_key(Operation::Backup, T::IDENTIFIER)) + .flatten(); + let content = T::backup(&model, key.as_deref())?; + file.replace_contents_future( + content, + None, + false, + gio::FileCreateFlags::REPLACE_DESTINATION, + ) + .await + .map_err(|e| e.1)?; + Ok(()) + } + + fn register_restore(&self, filters: &'static [&str]) { + let imp = self.imp(); + if T::ENCRYPTABLE { + let row = adw::ExpanderRow::builder() + .title(T::title()) + .subtitle(T::subtitle()) + .show_enable_switch(false) + .enable_expansion(true) + .use_underline(true) + .build(); + let key_entry = adw::PasswordEntryRow::builder() + .title(gettext("Key / Passphrase")) + .build(); + row.add_row(&key_entry); + + imp.key_entries + .borrow_mut() + .insert(format!("restore.{}", T::IDENTIFIER), key_entry); + + let button_row = adw::ActionRow::new(); + let key_button = gtk::Button::builder() + .valign(gtk::Align::Center) + .halign(gtk::Align::End) + .label(gettext("Select File")) + .action_name(format!("restore.{}", T::IDENTIFIER)) + .build(); + button_row.add_suffix(&key_button); + row.add_row(&button_row); + imp.restore_group.add(&row); + } else if T::SCANNABLE { + let menu_button = gtk::MenuButton::builder() + .css_classes(vec!["flat".to_string()]) + .halign(gtk::Align::Fill) + .valign(gtk::Align::Center) + .icon_name("qrscanner-symbolic") + .tooltip_text(gettext("Scan QR Code")) + .menu_model(&{ + let menu = gio::Menu::new(); + + menu.append( + Some(&gettext("_Camera")), + Some(&format!("restore.{}.camera", T::IDENTIFIER)), + ); + menu.append( + Some(&gettext("_Screenshot")), + Some(&format!("restore.{}.screenshot", T::IDENTIFIER)), + ); + + menu.append( + Some(&gettext("_QR Code Image")), + Some(&format!("restore.{}.file", T::IDENTIFIER)), + ); + + menu + }) + .build(); + + let row = adw::ActionRow::builder() + .title(T::title()) + .subtitle(T::subtitle()) + .activatable(true) + .activatable_widget(&menu_button) + .use_underline(true) + .build(); + + row.add_suffix(&menu_button); + + imp.restore_group.add(&row); + } else { + let row = adw::ActionRow::builder() + .title(T::title()) + .subtitle(T::subtitle()) + .activatable(true) + .use_underline(true) + .action_name(format!("restore.{}", T::IDENTIFIER)) + .build(); + + imp.restore_group.add(&row); + } + if T::SCANNABLE { + let camera_action = gio::ActionEntry::builder(&format!("{}.camera", T::IDENTIFIER)) + .activate(clone!( + #[weak(rename_to = win)] + self, + move |_, _, _| { + win.imp().actions.activate_action("show_camera_page", None); + spawn(clone!( + #[weak] + win, + async move { + if let Err(err) = win.restore_from_camera::().await { + tracing::error!("Failed to restore from camera {err}"); + win.add_toast(adw::Toast::new(&gettext( + "Failed to restore from camera", + ))); + } + } + )); + } + )) + .build(); + let screenshot_action = + gio::ActionEntry::builder(&format!("{}.screenshot", T::IDENTIFIER)) + .activate(clone!( + #[weak(rename_to = win)] + self, + move |_, _, _| { + spawn(clone!( + #[weak] + win, + async move { + if let Err(err) = + win.restore_from_screenshot::().await + { + tracing::error!( + "Failed to restore from a screenshot {err}" + ); + win.add_toast(adw::Toast::new(&gettext( + "Failed to restore from a screenshot", + ))); + } + } + )); + } + )) + .build(); + let file_action = gio::ActionEntry::builder(&format!("{}.file", T::IDENTIFIER)) + .activate(clone!( + #[weak(rename_to = win)] + self, + move |_, _, _| { + spawn(clone!( + #[weak] + win, + async move { + if let Err(err) = win.restore_from_image::().await { + tracing::error!("Failed to restore from an image {err}"); + win.add_toast(adw::Toast::new(&gettext( + "Failed to restore from an image", + ))); + } + } + )); + } + )) + .build(); + imp.restore_actions + .add_action_entries([camera_action, file_action, screenshot_action]); + } else { + let action = gio::ActionEntry::builder(T::IDENTIFIER) + .activate(clone!( + #[weak(rename_to = win)] + self, + move |_, _, _| { + spawn(clone!( + #[weak] + win, + async move { + if let Err(err) = win.restore_from_file::(filters).await + { + tracing::error!("Failed to restore from a file {err}"); + win.add_toast(adw::Toast::new(&gettext( + "Failed to restore from a file", + ))); + } + } + )); + } + )) + .build(); + + imp.restore_actions.add_action_entries([action]); + }; + } + async fn restore_from_file, Q: RestorableItem>( + &self, + filters: &'static [&str], + ) -> Result<()> { + let file = self.select_file(filters, Operation::Restore).await?; + let key = T::ENCRYPTABLE + .then(|| self.encryption_key(Operation::Restore, T::IDENTIFIER)) + .flatten(); + let content = file.load_contents_future().await?; + let items = T::restore_from_data(&content.0, key.as_deref())?; + self.restore_items::(items); + Ok(()) + } + + async fn restore_from_camera, Q: RestorableItem>(&self) -> Result<()> { + let code = self.imp().camera_page.scan_from_camera().await?; + let items = T::restore_from_data(code.as_bytes(), None)?; + self.restore_items::(items); + self.imp().actions.activate_action("close_page", None); + Ok(()) + } + + async fn restore_from_screenshot, Q: RestorableItem>( + &self, + ) -> Result<()> { + let code = self.imp().camera_page.scan_from_screenshot().await?; + let items = T::restore_from_data(code.as_bytes(), None)?; + self.restore_items::(items); + Ok(()) + } + + async fn restore_from_image, Q: RestorableItem>(&self) -> Result<()> { + let window = self.root().and_downcast::().unwrap(); + + let images_filter = gtk::FileFilter::new(); + images_filter.set_name(Some(&gettext("Image"))); + images_filter.add_pixbuf_formats(); + let model = gio::ListStore::new::(); + model.append(&images_filter); + + let dialog = gtk::FileDialog::builder() + .modal(true) + .filters(&model) + .title(gettext("Select QR Code")) + .build(); + let file = dialog.open_future(Some(&window)).await?; + let (data, _) = file.load_contents_future().await?; + let code = screenshot::scan(&data)?; + let items = T::restore_from_data(code.as_bytes(), None)?; + self.restore_items::(items); + Ok(()) + } + + fn encryption_key(&self, mode: Operation, identifier: &str) -> Option { + let identifier = match mode { + Operation::Backup => format!("backup.{identifier}",), + Operation::Restore => format!("restore.{identifier}"), + }; + self.imp() + .key_entries + .borrow() + .get(&identifier) + .map(|entry| entry.text()) + } + + fn restore_items, Q: RestorableItem>(&self, items: Vec) { + let model = self.model(); + items + .iter() + .map(move |item| item.restore(&model)) + .for_each(|item| { + if let Err(err) = item { + tracing::warn!("Failed to restore item {}", err); + } + }); + self.emit_by_name::<()>("restore-completed", &[]); + self.close(); + } + + async fn select_file( + &self, + filters: &'static [&str], + operation: Operation, + ) -> Result { + let filters_model = gio::ListStore::new::(); + let window = self.root().and_downcast::().unwrap(); + filters.iter().for_each(|f| { + let filter = gtk::FileFilter::new(); + filter.add_mime_type(f); + filter.set_name(Some(f)); + filters_model.append(&filter); + }); + + match operation { + Operation::Backup => { + let dialog = gtk::FileDialog::builder() + .modal(true) + .filters(&filters_model) + .title(gettext("Backup")) + .build(); + dialog.save_future(Some(&window)).await + } + Operation::Restore => { + let dialog = gtk::FileDialog::builder() + .modal(true) + .filters(&filters_model) + .title(gettext("Restore")) + .build(); + dialog.open_future(Some(&window)).await + } + } + } + + fn setup_actions(&self) { + let imp = self.imp(); + + let show_camera_page = gio::ActionEntry::builder("show_camera_page") + .activate(clone!( + #[weak(rename_to = win)] + self, + move |_, _, _| { + win.push_subpage(&win.imp().camera_page); + } + )) + .build(); + + let close_page = gio::ActionEntry::builder("close_page") + .activate(clone!( + #[weak(rename_to = win)] + self, + move |_, _, _| { + win.pop_subpage(); + } + )) + .build(); + + imp.actions + .add_action_entries([show_camera_page, close_page]); + + self.insert_action_group("backup", Some(&imp.backup_actions)); + self.insert_action_group("restore", Some(&imp.restore_actions)); + self.insert_action_group("camera-page", Some(&imp.actions)); + } +} diff --git a/src/widgets/backup/mod.rs b/src/widgets/backup/mod.rs new file mode 100644 index 0000000..f3bc636 --- /dev/null +++ b/src/widgets/backup/mod.rs @@ -0,0 +1,4 @@ +mod camera_page; +mod dialog; + +pub use dialog::BackupDialog; diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index ca7de2f..61be8bb 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -1,4 +1,5 @@ mod accounts; +mod backup; mod camera; mod camera_row; mod error_revealer; @@ -11,6 +12,7 @@ mod window; pub use self::{ accounts::AccountAddDialog, + backup::BackupDialog, camera::{screenshot, Camera}, camera_row::CameraRow, error_revealer::ErrorRevealer, diff --git a/src/widgets/preferences/mod.rs b/src/widgets/preferences/mod.rs index 545b191..c99fef9 100644 --- a/src/widgets/preferences/mod.rs +++ b/src/widgets/preferences/mod.rs @@ -1,4 +1,3 @@ -mod camera_page; mod password_page; mod window; diff --git a/src/widgets/preferences/window.rs b/src/widgets/preferences/window.rs index 3f8fb90..ca482be 100644 --- a/src/widgets/preferences/window.rs +++ b/src/widgets/preferences/window.rs @@ -1,32 +1,17 @@ use adw::prelude::*; -use anyhow::Result; -use gettextrs::gettext; use gtk::{ gio, glib::{self, clone}, subclass::prelude::*, }; -use super::{camera_page::CameraPage, password_page::PasswordPage}; -use crate::{ - backup::{ - Aegis, AndOTP, Backupable, Bitwarden, FreeOTP, FreeOTPJSON, Google, LegacyAuthenticator, - Operation, RaivoOTP, Restorable, RestorableItem, - }, - models::{ProvidersModel, SETTINGS}, - utils::spawn, - widgets::screenshot, -}; +use super::password_page::PasswordPage; +use crate::models::SETTINGS; mod imp { - use std::{ - cell::{Cell, OnceCell, RefCell}, - collections::HashMap, - sync::LazyLock, - }; + use std::cell::Cell; use adw::subclass::prelude::*; - use glib::subclass::Signal; use super::*; @@ -34,19 +19,10 @@ mod imp { #[properties(wrapper_type = super::PreferencesWindow)] #[template(resource = "/com/belmoussaoui/Authenticator/preferences.ui")] pub struct PreferencesWindow { - #[property(get, set, construct_only)] - pub model: OnceCell, #[property(get, set, construct)] pub has_set_password: Cell, pub actions: gio::SimpleActionGroup, - pub backup_actions: gio::SimpleActionGroup, - pub restore_actions: gio::SimpleActionGroup, - pub camera_page: CameraPage, pub password_page: PasswordPage, - #[template_child] - pub backup_group: TemplateChild, - #[template_child] - pub restore_group: TemplateChild, #[template_child(id = "auto_lock_switch")] pub auto_lock: TemplateChild, #[template_child(id = "download_favicons_switch")] @@ -55,7 +31,6 @@ mod imp { pub download_favicons_metered: TemplateChild, #[template_child(id = "lock_timeout_spin_btn")] pub lock_timeout: TemplateChild, - pub key_entries: RefCell>, } #[glib::object_subclass] @@ -69,19 +44,12 @@ mod imp { Self { has_set_password: Cell::default(), // Synced from the application - camera_page: CameraPage::new(&actions), password_page: PasswordPage::new(&actions), actions, - model: OnceCell::default(), - backup_actions: gio::SimpleActionGroup::new(), - restore_actions: gio::SimpleActionGroup::new(), auto_lock: TemplateChild::default(), download_favicons: TemplateChild::default(), download_favicons_metered: TemplateChild::default(), lock_timeout: TemplateChild::default(), - backup_group: TemplateChild::default(), - restore_group: TemplateChild::default(), - key_entries: RefCell::default(), } } @@ -96,12 +64,6 @@ mod imp { #[glib::derived_properties] impl ObjectImpl for PreferencesWindow { - fn signals() -> &'static [Signal] { - static SIGNALS: LazyLock> = - LazyLock::new(|| vec![Signal::builder("restore-completed").action().build()]); - SIGNALS.as_ref() - } - fn constructed(&self) { self.parent_constructed(); let obj = self.obj(); @@ -121,28 +83,8 @@ glib::wrapper! { } impl PreferencesWindow { - pub fn new(model: &ProvidersModel) -> Self { - glib::Object::builder().property("model", model).build() - } - - pub fn connect_restore_completed(&self, callback: F) -> glib::SignalHandlerId - where - F: Fn(&Self) + 'static, - { - self.connect_local( - "restore-completed", - false, - clone!( - #[weak(rename_to = win)] - self, - #[upgrade_or] - None, - move |_| { - callback(&win); - None - } - ), - ) + pub fn new() -> Self { + glib::Object::new() } fn setup_widget(&self) { @@ -164,407 +106,11 @@ impl PreferencesWindow { .sync_create() .bidirectional() .build(); - - // FreeOTP is first in all of these lists, since its the way to backup - // Authenticator for use with Authenticator. Others are sorted - // alphabetically. - - self.register_backup::(&["text/plain"]); - self.register_backup::(&["application/json"]); - self.register_backup::(&["application/json"]); - - self.register_restore::(&["text/plain"]); - self.register_restore::(&["application/json"]); - self.register_restore::(&["application/json"]); - self.register_restore::(&["application/json"]); - self.register_restore::(&["application/json"]); - self.register_restore::(&[]); - self.register_restore::(&["application/json"]); - self.register_restore::(&["application/zip"]); - } - - fn register_backup(&self, filters: &'static [&str]) { - let imp = self.imp(); - if T::ENCRYPTABLE { - let row = adw::ExpanderRow::builder() - .title(T::title()) - .subtitle(T::subtitle()) - .show_enable_switch(false) - .enable_expansion(true) - .use_underline(true) - .build(); - let key_entry = adw::PasswordEntryRow::builder() - .title(gettext("Key / Passphrase")) - .build(); - row.add_row(&key_entry); - imp.key_entries - .borrow_mut() - .insert(format!("backup.{}", T::IDENTIFIER), key_entry); - - let button_row = adw::ActionRow::new(); - let key_button = gtk::Button::builder() - .valign(gtk::Align::Center) - .halign(gtk::Align::End) - .label(gettext("Select File")) - .action_name(format!("backup.{}", T::IDENTIFIER)) - .build(); - button_row.add_suffix(&key_button); - row.add_row(&button_row); - - imp.backup_group.add(&row); - } else { - let row = adw::ActionRow::builder() - .title(T::title()) - .subtitle(T::subtitle()) - .activatable(true) - .use_underline(true) - .action_name(format!("backup.{}", T::IDENTIFIER)) - .build(); - - imp.backup_group.add(&row); - } - - let action = gio::ActionEntry::builder(T::IDENTIFIER) - .activate(clone!( - #[weak(rename_to = win)] - self, - move |_, _, _| { - spawn(clone!( - #[weak] - win, - async move { - if let Err(err) = win.backup_into_file::(filters).await { - tracing::error!("Failed to backup into a file {err}"); - win.add_toast(adw::Toast::new(&gettext( - "Failed to create a backup", - ))); - } - } - )); - } - )) - .build(); - imp.backup_actions.add_action_entries([action]); - } - - async fn backup_into_file(&self, filters: &'static [&str]) -> Result<()> { - let model = self.model(); - let file = self.select_file(filters, Operation::Backup).await?; - let key = T::ENCRYPTABLE - .then(|| self.encryption_key(Operation::Backup, T::IDENTIFIER)) - .flatten(); - let content = T::backup(&model, key.as_deref())?; - file.replace_contents_future( - content, - None, - false, - gio::FileCreateFlags::REPLACE_DESTINATION, - ) - .await - .map_err(|e| e.1)?; - Ok(()) - } - - fn register_restore(&self, filters: &'static [&str]) { - let imp = self.imp(); - if T::ENCRYPTABLE { - let row = adw::ExpanderRow::builder() - .title(T::title()) - .subtitle(T::subtitle()) - .show_enable_switch(false) - .enable_expansion(true) - .use_underline(true) - .build(); - let key_entry = adw::PasswordEntryRow::builder() - .title(gettext("Key / Passphrase")) - .build(); - row.add_row(&key_entry); - - imp.key_entries - .borrow_mut() - .insert(format!("restore.{}", T::IDENTIFIER), key_entry); - - let button_row = adw::ActionRow::new(); - let key_button = gtk::Button::builder() - .valign(gtk::Align::Center) - .halign(gtk::Align::End) - .label(gettext("Select File")) - .action_name(format!("restore.{}", T::IDENTIFIER)) - .build(); - button_row.add_suffix(&key_button); - row.add_row(&button_row); - imp.restore_group.add(&row); - } else if T::SCANNABLE { - let menu_button = gtk::MenuButton::builder() - .css_classes(vec!["flat".to_string()]) - .halign(gtk::Align::Fill) - .valign(gtk::Align::Center) - .icon_name("qrscanner-symbolic") - .tooltip_text(gettext("Scan QR Code")) - .menu_model(&{ - let menu = gio::Menu::new(); - - menu.append( - Some(&gettext("_Camera")), - Some(&format!("restore.{}.camera", T::IDENTIFIER)), - ); - menu.append( - Some(&gettext("_Screenshot")), - Some(&format!("restore.{}.screenshot", T::IDENTIFIER)), - ); - - menu.append( - Some(&gettext("_QR Code Image")), - Some(&format!("restore.{}.file", T::IDENTIFIER)), - ); - - menu - }) - .build(); - - let row = adw::ActionRow::builder() - .title(T::title()) - .subtitle(T::subtitle()) - .activatable(true) - .activatable_widget(&menu_button) - .use_underline(true) - .build(); - - row.add_suffix(&menu_button); - - imp.restore_group.add(&row); - } else { - let row = adw::ActionRow::builder() - .title(T::title()) - .subtitle(T::subtitle()) - .activatable(true) - .use_underline(true) - .action_name(format!("restore.{}", T::IDENTIFIER)) - .build(); - - imp.restore_group.add(&row); - } - if T::SCANNABLE { - let camera_action = gio::ActionEntry::builder(&format!("{}.camera", T::IDENTIFIER)) - .activate(clone!( - #[weak(rename_to = win)] - self, - move |_, _, _| { - win.imp().actions.activate_action("show_camera_page", None); - spawn(clone!( - #[weak] - win, - async move { - if let Err(err) = win.restore_from_camera::().await { - tracing::error!("Failed to restore from camera {err}"); - win.add_toast(adw::Toast::new(&gettext( - "Failed to restore from camera", - ))); - } - } - )); - } - )) - .build(); - let screenshot_action = - gio::ActionEntry::builder(&format!("{}.screenshot", T::IDENTIFIER)) - .activate(clone!( - #[weak(rename_to = win)] - self, - move |_, _, _| { - spawn(clone!( - #[weak] - win, - async move { - if let Err(err) = - win.restore_from_screenshot::().await - { - tracing::error!( - "Failed to restore from a screenshot {err}" - ); - win.add_toast(adw::Toast::new(&gettext( - "Failed to restore from a screenshot", - ))); - } - } - )); - } - )) - .build(); - let file_action = gio::ActionEntry::builder(&format!("{}.file", T::IDENTIFIER)) - .activate(clone!( - #[weak(rename_to = win)] - self, - move |_, _, _| { - spawn(clone!( - #[weak] - win, - async move { - if let Err(err) = win.restore_from_image::().await { - tracing::error!("Failed to restore from an image {err}"); - win.add_toast(adw::Toast::new(&gettext( - "Failed to restore from an image", - ))); - } - } - )); - } - )) - .build(); - imp.restore_actions - .add_action_entries([camera_action, file_action, screenshot_action]); - } else { - let action = gio::ActionEntry::builder(T::IDENTIFIER) - .activate(clone!( - #[weak(rename_to = win)] - self, - move |_, _, _| { - spawn(clone!( - #[weak] - win, - async move { - if let Err(err) = win.restore_from_file::(filters).await - { - tracing::error!("Failed to restore from a file {err}"); - win.add_toast(adw::Toast::new(&gettext( - "Failed to restore from a file", - ))); - } - } - )); - } - )) - .build(); - - imp.restore_actions.add_action_entries([action]); - }; - } - async fn restore_from_file, Q: RestorableItem>( - &self, - filters: &'static [&str], - ) -> Result<()> { - let file = self.select_file(filters, Operation::Restore).await?; - let key = T::ENCRYPTABLE - .then(|| self.encryption_key(Operation::Restore, T::IDENTIFIER)) - .flatten(); - let content = file.load_contents_future().await?; - let items = T::restore_from_data(&content.0, key.as_deref())?; - self.restore_items::(items); - Ok(()) - } - - async fn restore_from_camera, Q: RestorableItem>(&self) -> Result<()> { - let code = self.imp().camera_page.scan_from_camera().await?; - let items = T::restore_from_data(code.as_bytes(), None)?; - self.restore_items::(items); - self.imp().actions.activate_action("close_page", None); - Ok(()) - } - - async fn restore_from_screenshot, Q: RestorableItem>( - &self, - ) -> Result<()> { - let code = self.imp().camera_page.scan_from_screenshot().await?; - let items = T::restore_from_data(code.as_bytes(), None)?; - self.restore_items::(items); - Ok(()) - } - - async fn restore_from_image, Q: RestorableItem>(&self) -> Result<()> { - let window = self.root().and_downcast::().unwrap(); - - let images_filter = gtk::FileFilter::new(); - images_filter.set_name(Some(&gettext("Image"))); - images_filter.add_pixbuf_formats(); - let model = gio::ListStore::new::(); - model.append(&images_filter); - - let dialog = gtk::FileDialog::builder() - .modal(true) - .filters(&model) - .title(gettext("Select QR Code")) - .build(); - let file = dialog.open_future(Some(&window)).await?; - let (data, _) = file.load_contents_future().await?; - let code = screenshot::scan(&data)?; - let items = T::restore_from_data(code.as_bytes(), None)?; - self.restore_items::(items); - Ok(()) - } - - fn encryption_key(&self, mode: Operation, identifier: &str) -> Option { - let identifier = match mode { - Operation::Backup => format!("backup.{identifier}",), - Operation::Restore => format!("restore.{identifier}"), - }; - self.imp() - .key_entries - .borrow() - .get(&identifier) - .map(|entry| entry.text()) - } - - fn restore_items, Q: RestorableItem>(&self, items: Vec) { - let model = self.model(); - items - .iter() - .map(move |item| item.restore(&model)) - .for_each(|item| { - if let Err(err) = item { - tracing::warn!("Failed to restore item {}", err); - } - }); - self.emit_by_name::<()>("restore-completed", &[]); - self.close(); - } - - async fn select_file( - &self, - filters: &'static [&str], - operation: Operation, - ) -> Result { - let filters_model = gio::ListStore::new::(); - let window = self.root().and_downcast::().unwrap(); - filters.iter().for_each(|f| { - let filter = gtk::FileFilter::new(); - filter.add_mime_type(f); - filter.set_name(Some(f)); - filters_model.append(&filter); - }); - - match operation { - Operation::Backup => { - let dialog = gtk::FileDialog::builder() - .modal(true) - .filters(&filters_model) - .title(gettext("Backup")) - .build(); - dialog.save_future(Some(&window)).await - } - Operation::Restore => { - let dialog = gtk::FileDialog::builder() - .modal(true) - .filters(&filters_model) - .title(gettext("Restore")) - .build(); - dialog.open_future(Some(&window)).await - } - } } fn setup_actions(&self) { let imp = self.imp(); - let show_camera_page = gio::ActionEntry::builder("show_camera_page") - .activate(clone!( - #[weak(rename_to = win)] - self, - move |_, _, _| { - win.push_subpage(&win.imp().camera_page); - } - )) - .build(); - let show_password_page = gio::ActionEntry::builder("show_password_page") .activate(clone!( #[weak(rename_to = win)] @@ -586,10 +132,8 @@ impl PreferencesWindow { .build(); imp.actions - .add_action_entries([show_camera_page, show_password_page, close_page]); + .add_action_entries([show_password_page, close_page]); self.insert_action_group("preferences", Some(&imp.actions)); - self.insert_action_group("backup", Some(&imp.backup_actions)); - self.insert_action_group("restore", Some(&imp.restore_actions)); } }