use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use adw::prelude::*;
use futures_util::StreamExt;
use gettextrs::gettext;
use gtk::{
gio,
glib::{self, clone},
subclass::prelude::*,
};
use search_provider::ResultMeta;
use crate::{
config,
models::{
keyring, start as start_search_provider, Account, OTPUri, Provider, ProvidersModel,
SearchProviderAction, FAVICONS_PATH, RUNTIME, SECRET_SERVICE, SETTINGS,
},
utils::{spawn, spawn_tokio_blocking},
widgets::{BackupDialog, KeyringErrorDialog, PreferencesWindow, ProvidersDialog, Window},
};
mod imp {
use std::cell::{Cell, RefCell};
use adw::subclass::prelude::*;
use super::*;
#[derive(Default, glib::Properties)]
#[properties(wrapper_type = super::Application)]
pub struct Application {
pub window: RefCell<Option<glib::WeakRef<Window>>>,
pub model: ProvidersModel,
#[property(get, set, construct)]
pub is_locked: Cell<bool>,
pub lock_timeout_id: RefCell<Option<glib::Source>>,
#[property(get, set, construct)]
pub can_be_locked: Cell<bool>,
#[property(get, set, construct_only)]
pub is_keyring_open: Cell<bool>,
}
#[glib::object_subclass]
impl ObjectSubclass for Application {
const NAME: &'static str = "Application";
type ParentType = adw::Application;
type Type = super::Application;
}
#[glib::derived_properties]
impl ObjectImpl for Application {}
impl ApplicationImpl for Application {
fn startup(&self) {
self.parent_startup();
let app = self.obj();
let quit_action = gio::ActionEntry::builder("quit")
.activate(|app: &Self::Type, _, _| app.quit())
.build();
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 = BackupDialog::new(model);
preferences.connect_restore_completed(clone!(
#[weak]
window,
move |_| {
window.providers().refilter();
window
.imp()
.toast_overlay
.add_toast(adw::Toast::new(&gettext(
"Accounts restored successfully",
)));
}
));
preferences.present(Some(&window));
})
.build();
let preferences_action = gio::ActionEntry::builder("preferences")
.activate(|app: &Self::Type, _, _| {
let window = app.active_window();
let preferences = PreferencesWindow::default();
preferences.set_has_set_password(app.can_be_locked());
preferences.connect_has_set_password_notify(clone!(
#[weak]
app,
move |pref| {
app.set_can_be_locked(pref.has_set_password());
}
));
preferences.present(Some(&window));
})
.build();
let about_action = gio::ActionEntry::builder("about")
.activate(|app: &Self::Type, _, _| {
let window = app.active_window();
adw::AboutDialog::builder()
.application_name(gettext("Authenticator"))
.version(config::VERSION)
.comments(gettext("Generate two-factor codes"))
.website("https://gitlab.gnome.org/World/Authenticator")
.developers(vec![
"Bilal Elmoussaoui",
"Maximiliano Sandoval",
"Christopher Davis",
"Julia Johannesen",
])
.artists(vec!["Alexandros Felekidis", "Tobias Bernard"])
.translator_credits(gettext("translator-credits"))
.application_icon(config::APP_ID)
.license_type(gtk::License::Gpl30)
.build()
.present(Some(&window));
})
.build();
let providers_action = gio::ActionEntry::builder("providers")
.activate(|app: &Self::Type, _, _| {
let model = &app.imp().model;
let window = app.active_window();
let providers = ProvidersDialog::new(model);
providers.connect_changed(clone!(
#[weak]
window,
move |_| {
window.providers().refilter();
}
));
providers.present(Some(&window));
})
.build();
let lock_action = gio::ActionEntry::builder("lock")
.activate(|app: &Self::Type, _, _| app.set_is_locked(true))
.build();
app.add_action_entries([
quit_action,
about_action,
lock_action,
providers_action,
preferences_action,
show_backup_dialog_action,
]);
let lock_action = app.lookup_action("lock").unwrap();
let preferences_action = app.lookup_action("preferences").unwrap();
let providers_action = app.lookup_action("providers").unwrap();
app.bind_property("can-be-locked", &lock_action, "enabled")
.sync_create()
.build();
app.bind_property("is-locked", &preferences_action, "enabled")
.invert_boolean()
.sync_create()
.build();
app.bind_property("is-locked", &providers_action, "enabled")
.invert_boolean()
.sync_create()
.build();
app.connect_can_be_locked_notify(|app| {
if !app.can_be_locked() {
app.cancel_lock_timeout();
}
});
SETTINGS.connect_auto_lock_changed(clone!(
#[weak]
app,
move |auto_lock| {
if auto_lock {
app.restart_lock_timeout();
} else {
app.cancel_lock_timeout();
}
}
));
SETTINGS.connect_auto_lock_timeout_changed(clone!(
#[weak]
app,
move |_| app.restart_lock_timeout()
));
spawn(clone!(
#[strong]
app,
async move {
app.start_search_provider().await;
}
));
}
fn activate(&self) {
let app = self.obj();
if !app.is_keyring_open() {
app.present_error_window();
return;
}
if let Some(ref win) = *self.window.borrow() {
let window = win.upgrade().unwrap();
window.present();
return;
}
let window = Window::new(&self.model, &app);
window.present();
self.window.replace(Some(window.downgrade()));
app.set_accels_for_action("app.quit", &["<primary>q"]);
app.set_accels_for_action("app.lock", &["<primary>l"]);
app.set_accels_for_action("app.providers", &["<primary>p"]);
app.set_accels_for_action("app.preferences", &["<primary>comma"]);
app.set_accels_for_action("win.show-help-overlay", &["<primary>question"]);
app.set_accels_for_action("win.search", &["<primary>f"]);
app.set_accels_for_action("win.add_account", &["<primary>n"]);
app.set_accels_for_action("window.close", &["<primary>w"]);
app.restart_lock_timeout();
}
fn open(&self, files: &[gio::File], _hint: &str) {
self.activate();
let uris = files
.iter()
.filter_map(|f| f.uri().parse::<OTPUri>().ok())
.collect::<Vec<OTPUri>>();
if let Some(uri) = uris.first() {
let window = self.obj().active_window();
window.open_add_account(Some(uri))
}
}
}
impl GtkApplicationImpl for Application {}
impl AdwApplicationImpl for Application {}
}
glib::wrapper! {
pub struct Application(ObjectSubclass<imp::Application>)
@extends gio::Application, gtk::Application, adw::Application,
@implements gio::ActionMap, gio::ActionGroup;
}
impl Application {
pub fn run() -> glib::ExitCode {
tracing::info!("Authenticator ({})", config::APP_ID);
tracing::info!("Version: {} ({})", config::VERSION, config::PROFILE);
tracing::info!("Datadir: {}", config::PKGDATADIR);
std::fs::create_dir_all(&*FAVICONS_PATH).ok();
if !SETTINGS.keyrings_migrated() {
tracing::info!("Migrating the secrets to the file backend");
let output: oo7::Result<()> = RUNTIME.block_on(async {
oo7::migrate(
vec![
HashMap::from([("application", config::APP_ID), ("type", "token")]),
HashMap::from([("application", config::APP_ID), ("type", "password")]),
],
false,
)
.await?;
Ok(())
});
match output {
Ok(_) => {
SETTINGS
.set_keyrings_migrated(true)
.expect("Failed to update settings");
tracing::info!("Secrets were migrated successfully");
}
Err(err) => {
tracing::error!("Failed to migrate your data {err}");
}
}
}
let is_keyring_open = spawn_tokio_blocking(async {
match oo7::Keyring::new().await {
Ok(keyring) => {
if let Err(err) = keyring.unlock().await {
tracing::error!("Could not unlock keyring: {err}");
false
} else {
SECRET_SERVICE.set(keyring).unwrap();
true
}
}
Err(err) => {
tracing::error!("Could not open keyring: {err}");
false
}
}
});
let has_set_password = if is_keyring_open {
spawn_tokio_blocking(async { keyring::has_set_password().await.unwrap_or(false) })
} else {
false
};
let app = glib::Object::builder::<Application>()
.property("application-id", config::APP_ID)
.property("flags", gio::ApplicationFlags::HANDLES_OPEN)
.property("resource-base-path", "/com/belmoussaoui/Authenticator")
.property("is-locked", has_set_password)
.property("can-be-locked", has_set_password)
.property("is-keyring-open", is_keyring_open)
.build();
if !has_set_password && is_keyring_open {
app.imp().model.load();
}
app.run()
}
pub fn active_window(&self) -> Window {
self.imp()
.window
.borrow()
.as_ref()
.unwrap()
.upgrade()
.unwrap()
}
pub fn restart_lock_timeout(&self) {
let imp = self.imp();
let auto_lock = SETTINGS.auto_lock();
let timeout = SETTINGS.auto_lock_timeout() * 60;
if !auto_lock {
return;
}
self.cancel_lock_timeout();
if !self.is_locked() && self.can_be_locked() {
let (tx, rx) = futures_channel::oneshot::channel::<()>();
let tx = Arc::new(Mutex::new(Some(tx)));
let id = glib::source::timeout_source_new_seconds(
timeout,
None,
glib::Priority::HIGH,
clone!(
#[strong]
tx,
move || {
let Some(tx) = tx.lock().unwrap().take() else {
return glib::ControlFlow::Break;
};
tx.send(()).unwrap();
glib::ControlFlow::Break
}
),
);
spawn(clone!(
#[strong(rename_to = app)]
self,
async move {
if let Ok(()) = rx.await {
app.set_is_locked(true);
}
}
));
id.attach(Some(&glib::MainContext::default()));
imp.lock_timeout_id.replace(Some(id));
}
}
fn cancel_lock_timeout(&self) {
if let Some(id) = self.imp().lock_timeout_id.borrow_mut().take() {
id.destroy();
}
}
fn account_provider_by_identifier(&self, id: &str) -> Option<(Provider, Account)> {
let identifier = id.split(':').collect::<Vec<&str>>();
let provider_id = identifier.first()?.parse::<u32>().ok()?;
let account_id = identifier.get(1)?.parse::<u32>().ok()?;
let provider = self.imp().model.find_by_id(provider_id)?;
let account = provider.accounts_model().find_by_id(account_id)?;
Some((provider, account))
}
async fn start_search_provider(&self) {
let mut receiver = match start_search_provider().await {
Err(err) => {
tracing::error!("Failed to start search provider {err}");
return;
}
Ok(receiver) => receiver,
};
loop {
let response = receiver.next().await.unwrap();
match response {
SearchProviderAction::LaunchSearch(terms) => {
self.activate();
let window = self.active_window();
window.imp().search_entry.set_text(&terms.join(" "));
window.imp().search_btn.set_active(true);
window.present();
}
SearchProviderAction::ActivateResult(id) => {
let notification = gio::Notification::new(&gettext("One-Time password copied"));
notification.set_body(Some(&gettext("Password was copied successfully")));
self.send_notification(Some(&id), ¬ification);
let Some((provider, _)) = self.account_provider_by_identifier(&id) else {
return;
};
glib::timeout_add_seconds_local_once(
provider.period(),
glib::clone!(
#[weak(rename_to = app)]
self,
move || {
app.withdraw_notification(&id);
}
),
);
}
SearchProviderAction::InitialResultSet(terms, sender) => {
let response = if self.is_locked() {
vec![]
} else {
self.imp()
.model
.find_accounts(&terms)
.into_iter()
.map(|account| format!("{}:{}", account.provider().id(), account.id()))
.collect::<Vec<_>>()
};
sender.send(response).unwrap();
}
SearchProviderAction::ResultMetas(identifiers, sender) => {
let metas = identifiers
.iter()
.filter_map(|id| {
self.account_provider_by_identifier(id)
.map(|(provider, account)| {
ResultMeta::builder(id.to_owned(), &account.name())
.description(&provider.name())
.clipboard_text(&account.code().replace(' ', ""))
.build()
})
})
.collect::<Vec<_>>();
sender.send(metas).unwrap();
}
}
}
}
fn present_error_window(&self) {
let dialog = KeyringErrorDialog::new(self);
dialog.present();
}
}