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
This commit is contained in:
Maximiliano Sandoval 2025-01-01 10:04:58 +01:00 committed by Bilal Elmoussaoui
parent 2721b9145d
commit 293a76d67f
12 changed files with 605 additions and 485 deletions

View file

@ -16,6 +16,7 @@
<file compressed="true" preprocess="xml-stripblanks" alias="providers_list.ui">resources/ui/providers_list.ui</file>
<!-- UI Files -->
<file compressed="true" preprocess="xml-stripblanks" alias="backup_dialog.ui">resources/ui/backup_dialog.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="preferences.ui">resources/ui/preferences.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="preferences_camera_page.ui">resources/ui/preferences_camera_page.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="preferences_password_page.ui">resources/ui/preferences_password_page.ui</file>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="BackupDialog" parent="AdwPreferencesDialog">
<property name="title" translatable="yes">Backup &amp; Restore</property>
<child>
<object class="AdwPreferencesPage">
<property name="icon-name">document-save-as-symbolic</property>
<property name="title" translatable="yes">Backup &amp; Restore</property>
<child>
<object class="AdwPreferencesGroup" id="backup_group">
<property name="title" translatable="yes">Backup</property>
</object>
</child>
<child>
<object class="AdwPreferencesGroup" id="restore_group">
<property name="title" translatable="yes">Restore</property>
</object>
</child>
</object>
</child>
</template>
</interface>

View file

@ -7,7 +7,6 @@
<property name="page-increment">10</property>
</object>
<template class="PreferencesWindow" parent="AdwPreferencesDialog">
<property name="content-width">550</property>
<property name="content-height">570</property>
<child>
<object class="AdwPreferencesPage">
@ -74,21 +73,5 @@
</child>
</object>
</child>
<child>
<object class="AdwPreferencesPage">
<property name="icon-name">document-save-as-symbolic</property>
<property name="title" translatable="yes">Backup/Restore</property>
<child>
<object class="AdwPreferencesGroup" id="backup_group">
<property name="title" translatable="yes">Backup</property>
</object>
</child>
<child>
<object class="AdwPreferencesGroup" id="restore_group">
<property name="title" translatable="yes">Restore</property>
</object>
</child>
</object>
</child>
</template>
</interface>

View file

@ -16,6 +16,10 @@
<attribute name="label" translatable="yes">_Preferences</attribute>
<attribute name="action">app.preferences</attribute>
</item>
<item>
<attribute name="label" translatable="yes">_Backup &amp; Restore</attribute>
<attribute name="action">app.show-backup-dialog</attribute>
</item>
</section>
<section>
<item>

View file

@ -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

View file

@ -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();

View file

@ -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<ProvidersModel>,
pub actions: gio::SimpleActionGroup,
pub backup_actions: gio::SimpleActionGroup,
pub restore_actions: gio::SimpleActionGroup,
pub camera_page: CameraPage,
pub key_entries: RefCell<HashMap<String, adw::PasswordEntryRow>>,
#[template_child]
pub backup_group: TemplateChild<adw::PreferencesGroup>,
#[template_child]
pub restore_group: TemplateChild<adw::PreferencesGroup>,
}
#[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<Self>) {
obj.init_template();
}
}
#[glib::derived_properties]
impl ObjectImpl for BackupDialog {
fn signals() -> &'static [Signal] {
static SIGNALS: LazyLock<Vec<Signal>> =
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<imp::BackupDialog>)
@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<F>(&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::<FreeOTP>(&["text/plain"]);
self.register_backup::<Aegis>(&["application/json"]);
self.register_backup::<AndOTP>(&["application/json"]);
self.register_restore::<FreeOTP>(&["text/plain"]);
self.register_restore::<FreeOTPJSON>(&["application/json"]);
self.register_restore::<Aegis>(&["application/json"]);
self.register_restore::<AndOTP>(&["application/json"]);
self.register_restore::<Bitwarden>(&["application/json"]);
self.register_restore::<Google>(&[]);
self.register_restore::<LegacyAuthenticator>(&["application/json"]);
self.register_restore::<RaivoOTP>(&["application/zip"]);
}
fn register_backup<T: Backupable>(&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::<T>(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<T: Backupable>(&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<T: Restorable>(&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::<T, T::Item>().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::<T, T::Item>().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::<T, T::Item>().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::<T, T::Item>(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<T: Restorable<Item = Q>, 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::<T, T::Item>(items);
Ok(())
}
async fn restore_from_camera<T: Restorable<Item = Q>, 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::<T, T::Item>(items);
self.imp().actions.activate_action("close_page", None);
Ok(())
}
async fn restore_from_screenshot<T: Restorable<Item = Q>, 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::<T, T::Item>(items);
Ok(())
}
async fn restore_from_image<T: Restorable<Item = Q>, Q: RestorableItem>(&self) -> Result<()> {
let window = self.root().and_downcast::<gtk::Window>().unwrap();
let images_filter = gtk::FileFilter::new();
images_filter.set_name(Some(&gettext("Image")));
images_filter.add_pixbuf_formats();
let model = gio::ListStore::new::<gtk::FileFilter>();
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::<T, T::Item>(items);
Ok(())
}
fn encryption_key(&self, mode: Operation, identifier: &str) -> Option<glib::GString> {
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<T: Restorable<Item = Q>, Q: RestorableItem>(&self, items: Vec<Q>) {
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<gio::File, glib::Error> {
let filters_model = gio::ListStore::new::<gtk::FileFilter>();
let window = self.root().and_downcast::<gtk::Window>().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));
}
}

View file

@ -0,0 +1,4 @@
mod camera_page;
mod dialog;
pub use dialog::BackupDialog;

View file

@ -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,

View file

@ -1,4 +1,3 @@
mod camera_page;
mod password_page;
mod window;

View file

@ -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<ProvidersModel>,
#[property(get, set, construct)]
pub has_set_password: Cell<bool>,
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<adw::PreferencesGroup>,
#[template_child]
pub restore_group: TemplateChild<adw::PreferencesGroup>,
#[template_child(id = "auto_lock_switch")]
pub auto_lock: TemplateChild<adw::SwitchRow>,
#[template_child(id = "download_favicons_switch")]
@ -55,7 +31,6 @@ mod imp {
pub download_favicons_metered: TemplateChild<adw::SwitchRow>,
#[template_child(id = "lock_timeout_spin_btn")]
pub lock_timeout: TemplateChild<adw::SpinRow>,
pub key_entries: RefCell<HashMap<String, adw::PasswordEntryRow>>,
}
#[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<Vec<Signal>> =
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<F>(&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::<FreeOTP>(&["text/plain"]);
self.register_backup::<Aegis>(&["application/json"]);
self.register_backup::<AndOTP>(&["application/json"]);
self.register_restore::<FreeOTP>(&["text/plain"]);
self.register_restore::<FreeOTPJSON>(&["application/json"]);
self.register_restore::<Aegis>(&["application/json"]);
self.register_restore::<AndOTP>(&["application/json"]);
self.register_restore::<Bitwarden>(&["application/json"]);
self.register_restore::<Google>(&[]);
self.register_restore::<LegacyAuthenticator>(&["application/json"]);
self.register_restore::<RaivoOTP>(&["application/zip"]);
}
fn register_backup<T: Backupable>(&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::<T>(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<T: Backupable>(&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<T: Restorable>(&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::<T, T::Item>().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::<T, T::Item>().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::<T, T::Item>().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::<T, T::Item>(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<T: Restorable<Item = Q>, 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::<T, T::Item>(items);
Ok(())
}
async fn restore_from_camera<T: Restorable<Item = Q>, 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::<T, T::Item>(items);
self.imp().actions.activate_action("close_page", None);
Ok(())
}
async fn restore_from_screenshot<T: Restorable<Item = Q>, 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::<T, T::Item>(items);
Ok(())
}
async fn restore_from_image<T: Restorable<Item = Q>, Q: RestorableItem>(&self) -> Result<()> {
let window = self.root().and_downcast::<gtk::Window>().unwrap();
let images_filter = gtk::FileFilter::new();
images_filter.set_name(Some(&gettext("Image")));
images_filter.add_pixbuf_formats();
let model = gio::ListStore::new::<gtk::FileFilter>();
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::<T, T::Item>(items);
Ok(())
}
fn encryption_key(&self, mode: Operation, identifier: &str) -> Option<glib::GString> {
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<T: Restorable<Item = Q>, Q: RestorableItem>(&self, items: Vec<Q>) {
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<gio::File, glib::Error> {
let filters_model = gio::ListStore::new::<gtk::FileFilter>();
let window = self.root().and_downcast::<gtk::Window>().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));
}
}