authenticator/
application.rs

1use std::{
2    collections::HashMap,
3    sync::{Arc, Mutex},
4};
5
6use adw::prelude::*;
7use futures_util::StreamExt;
8use gettextrs::gettext;
9use gtk::{
10    gio,
11    glib::{self, clone},
12    subclass::prelude::*,
13};
14use search_provider::ResultMeta;
15
16use crate::{
17    config,
18    models::{
19        Account, FAVICONS_PATH, OTPUri, Provider, ProvidersModel, RUNTIME, SECRET_SERVICE,
20        SETTINGS, SearchProviderAction, keyring, start as start_search_provider,
21    },
22    utils::{spawn, spawn_tokio_blocking},
23    widgets::{BackupDialog, KeyringErrorDialog, PreferencesWindow, ProvidersDialog, Window},
24};
25
26mod imp {
27    use std::cell::{Cell, RefCell};
28
29    use adw::subclass::prelude::*;
30
31    use super::*;
32
33    // The basic struct that holds our state and widgets
34    // (Ref)Cells are used for members which need to be mutable
35    #[derive(Default, glib::Properties)]
36    #[properties(wrapper_type = super::Application)]
37    pub struct Application {
38        pub window: RefCell<Option<glib::WeakRef<Window>>>,
39        pub model: ProvidersModel,
40        #[property(get, set, construct)]
41        pub is_locked: Cell<bool>,
42        pub lock_timeout_id: RefCell<Option<glib::Source>>,
43        #[property(get, set, construct)]
44        pub can_be_locked: Cell<bool>,
45        #[property(get, set, construct_only)]
46        pub is_keyring_open: Cell<bool>,
47    }
48
49    // Sets up the basics for the GObject
50    #[glib::object_subclass]
51    impl ObjectSubclass for Application {
52        const NAME: &'static str = "Application";
53        type ParentType = adw::Application;
54        type Type = super::Application;
55    }
56
57    #[glib::derived_properties]
58    impl ObjectImpl for Application {}
59
60    // Overrides GApplication vfuncs
61    impl ApplicationImpl for Application {
62        fn startup(&self) {
63            self.parent_startup();
64            let app = self.obj();
65            let quit_action = gio::ActionEntry::builder("quit")
66                .activate(|app: &Self::Type, _, _| app.quit())
67                .build();
68
69            let show_backup_dialog_action = gio::ActionEntry::builder("show-backup-dialog")
70                .activate(|app: &Self::Type, _, _| {
71                    let model = &app.imp().model;
72                    let window = app.active_window();
73                    let preferences = BackupDialog::new(model);
74                    preferences.connect_restore_completed(clone!(
75                        #[weak]
76                        window,
77                        move |_| {
78                            window.providers().refilter();
79                            window
80                                .imp()
81                                .toast_overlay
82                                .add_toast(adw::Toast::new(&gettext(
83                                    "Accounts restored successfully",
84                                )));
85                        }
86                    ));
87                    preferences.present(Some(&window));
88                })
89                .build();
90
91            let preferences_action = gio::ActionEntry::builder("preferences")
92                .activate(|app: &Self::Type, _, _| {
93                    let window = app.active_window();
94                    let preferences = PreferencesWindow::default();
95                    preferences.set_has_set_password(app.can_be_locked());
96                    preferences.connect_has_set_password_notify(clone!(
97                        #[weak]
98                        app,
99                        move |pref| {
100                            app.set_can_be_locked(pref.has_set_password());
101                        }
102                    ));
103                    preferences.present(Some(&window));
104                })
105                .build();
106
107            // About
108            let about_action = gio::ActionEntry::builder("about")
109                .activate(|app: &Self::Type, _, _| {
110                    let window = app.active_window();
111                    adw::AboutDialog::builder()
112                        .application_name(gettext("Authenticator"))
113                        .version(config::VERSION)
114                        .comments(gettext("Generate two-factor codes"))
115                        .website("https://gitlab.gnome.org/World/Authenticator")
116                        .developers(vec![
117                            "Bilal Elmoussaoui",
118                            "Maximiliano Sandoval",
119                            "Christopher Davis",
120                            "Julia Johannesen",
121                        ])
122                        .artists(vec!["Alexandros Felekidis", "Tobias Bernard"])
123                        .translator_credits(gettext("translator-credits"))
124                        .application_icon(config::APP_ID)
125                        .license_type(gtk::License::Gpl30)
126                        .build()
127                        .present(Some(&window));
128                })
129                .build();
130
131            let providers_action = gio::ActionEntry::builder("providers")
132                .activate(|app: &Self::Type, _, _| {
133                    let model = &app.imp().model;
134                    let window = app.active_window();
135                    let providers = ProvidersDialog::new(model);
136                    providers.connect_changed(clone!(
137                        #[weak]
138                        window,
139                        move |_| {
140                            window.providers().refilter();
141                        }
142                    ));
143                    providers.present(Some(&window));
144                })
145                .build();
146
147            let lock_action = gio::ActionEntry::builder("lock")
148                .activate(|app: &Self::Type, _, _| app.set_is_locked(true))
149                .build();
150
151            app.add_action_entries([
152                quit_action,
153                about_action,
154                lock_action,
155                providers_action,
156                preferences_action,
157                show_backup_dialog_action,
158            ]);
159
160            let lock_action = app.lookup_action("lock").unwrap();
161            let preferences_action = app.lookup_action("preferences").unwrap();
162            let providers_action = app.lookup_action("providers").unwrap();
163            app.bind_property("can-be-locked", &lock_action, "enabled")
164                .sync_create()
165                .build();
166            app.bind_property("is-locked", &preferences_action, "enabled")
167                .invert_boolean()
168                .sync_create()
169                .build();
170            app.bind_property("is-locked", &providers_action, "enabled")
171                .invert_boolean()
172                .sync_create()
173                .build();
174
175            app.connect_can_be_locked_notify(|app| {
176                if !app.can_be_locked() {
177                    app.cancel_lock_timeout();
178                }
179            });
180
181            SETTINGS.connect_auto_lock_changed(clone!(
182                #[weak]
183                app,
184                move |auto_lock| {
185                    if auto_lock {
186                        app.restart_lock_timeout();
187                    } else {
188                        app.cancel_lock_timeout();
189                    }
190                }
191            ));
192
193            SETTINGS.connect_auto_lock_timeout_changed(clone!(
194                #[weak]
195                app,
196                move |_| app.restart_lock_timeout()
197            ));
198
199            spawn(clone!(
200                #[strong]
201                app,
202                async move {
203                    app.start_search_provider().await;
204                }
205            ));
206        }
207
208        fn activate(&self) {
209            let app = self.obj();
210
211            if !app.is_keyring_open() {
212                app.present_error_window();
213                return;
214            }
215
216            if let Some(ref win) = *self.window.borrow() {
217                let window = win.upgrade().unwrap();
218                window.present();
219                return;
220            }
221
222            let window = Window::new(&self.model, &app);
223            window.present();
224            self.window.replace(Some(window.downgrade()));
225
226            app.set_accels_for_action("app.quit", &["<primary>q"]);
227            app.set_accels_for_action("app.lock", &["<primary>l"]);
228            app.set_accels_for_action("app.providers", &["<primary>p"]);
229            app.set_accels_for_action("app.preferences", &["<primary>comma"]);
230            app.set_accels_for_action("win.search", &["<primary>f"]);
231            app.set_accels_for_action("win.add_account", &["<primary>n"]);
232            app.set_accels_for_action("window.close", &["<primary>w"]);
233            // Start the timeout to lock the app if the auto-lock
234            // setting is enabled.
235            app.restart_lock_timeout();
236        }
237
238        fn open(&self, files: &[gio::File], _hint: &str) {
239            self.activate();
240            let uris = files
241                .iter()
242                .filter_map(|f| f.uri().parse::<OTPUri>().ok())
243                .collect::<Vec<OTPUri>>();
244            // We only handle a single URI (see the desktop file)
245            if let Some(uri) = uris.first() {
246                let window = self.obj().active_window();
247                window.open_add_account(Some(uri))
248            }
249        }
250    }
251    // This is empty, but we still need to provide an
252    // empty implementation for each type we subclass.
253    impl GtkApplicationImpl for Application {}
254
255    impl AdwApplicationImpl for Application {}
256}
257
258// Creates a wrapper struct that inherits the functions
259// from objects listed it @extends or interfaces it @implements.
260// This is what allows us to do e.g. application.quit() on
261// Application without casting.
262glib::wrapper! {
263    pub struct Application(ObjectSubclass<imp::Application>)
264        @extends gio::Application, gtk::Application, adw::Application,
265        @implements gio::ActionMap, gio::ActionGroup;
266}
267
268impl Application {
269    pub fn run() -> glib::ExitCode {
270        tracing::info!("Authenticator ({})", config::APP_ID);
271        tracing::info!("Version: {} ({})", config::VERSION, config::PROFILE);
272        tracing::info!("Datadir: {}", config::PKGDATADIR);
273
274        std::fs::create_dir_all(&*FAVICONS_PATH).ok();
275
276        // To be removed in the upcoming release
277        if !SETTINGS.keyrings_migrated() {
278            tracing::info!("Migrating the secrets to the file backend");
279            let output: oo7::Result<()> = RUNTIME.block_on(async {
280                oo7::migrate(
281                    vec![
282                        HashMap::from([("application", config::APP_ID), ("type", "token")]),
283                        HashMap::from([("application", config::APP_ID), ("type", "password")]),
284                    ],
285                    false,
286                )
287                .await?;
288                Ok(())
289            });
290            match output {
291                Ok(_) => {
292                    SETTINGS
293                        .set_keyrings_migrated(true)
294                        .expect("Failed to update settings");
295                    tracing::info!("Secrets were migrated successfully");
296                }
297                Err(err) => {
298                    tracing::error!("Failed to migrate your data {err}");
299                }
300            }
301        }
302
303        let is_keyring_open = spawn_tokio_blocking(async {
304            match oo7::Keyring::new().await {
305                Ok(keyring) => {
306                    if let Err(err) = keyring.unlock().await {
307                        tracing::error!("Could not unlock keyring: {err}");
308                        false
309                    } else {
310                        SECRET_SERVICE.set(keyring).unwrap();
311                        true
312                    }
313                }
314                Err(err) => {
315                    tracing::error!("Could not open keyring: {err}");
316                    false
317                }
318            }
319        });
320
321        let has_set_password = if is_keyring_open {
322            spawn_tokio_blocking(async { keyring::has_set_password().await.unwrap_or(false) })
323        } else {
324            false
325        };
326        let app = glib::Object::builder::<Application>()
327            .property("application-id", config::APP_ID)
328            .property("flags", gio::ApplicationFlags::HANDLES_OPEN)
329            .property("resource-base-path", "/com/belmoussaoui/Authenticator")
330            .property("is-locked", has_set_password)
331            .property("can-be-locked", has_set_password)
332            .property("is-keyring-open", is_keyring_open)
333            .build();
334        // Only load the model if the app is not locked
335        if !has_set_password && is_keyring_open {
336            app.imp().model.load();
337        }
338
339        app.run()
340    }
341
342    pub fn active_window(&self) -> Window {
343        self.imp()
344            .window
345            .borrow()
346            .as_ref()
347            .unwrap()
348            .upgrade()
349            .unwrap()
350    }
351
352    /// Starts or restarts the lock timeout.
353    pub fn restart_lock_timeout(&self) {
354        let imp = self.imp();
355        let auto_lock = SETTINGS.auto_lock();
356        let timeout = SETTINGS.auto_lock_timeout() * 60;
357
358        if !auto_lock {
359            return;
360        }
361
362        self.cancel_lock_timeout();
363
364        if !self.is_locked() && self.can_be_locked() {
365            let (tx, rx) = futures_channel::oneshot::channel::<()>();
366            let tx = Arc::new(Mutex::new(Some(tx)));
367            let id = glib::source::timeout_source_new_seconds(
368                timeout,
369                None,
370                glib::Priority::HIGH,
371                clone!(
372                    #[strong]
373                    tx,
374                    move || {
375                        let Some(tx) = tx.lock().unwrap().take() else {
376                            return glib::ControlFlow::Break;
377                        };
378                        tx.send(()).unwrap();
379                        glib::ControlFlow::Break
380                    }
381                ),
382            );
383            spawn(clone!(
384                #[strong(rename_to = app)]
385                self,
386                async move {
387                    if let Ok(()) = rx.await {
388                        app.set_is_locked(true);
389                    }
390                }
391            ));
392            id.attach(Some(&glib::MainContext::default()));
393            imp.lock_timeout_id.replace(Some(id));
394        }
395    }
396
397    fn cancel_lock_timeout(&self) {
398        if let Some(id) = self.imp().lock_timeout_id.borrow_mut().take() {
399            id.destroy();
400        }
401    }
402
403    fn account_provider_by_identifier(&self, id: &str) -> Option<(Provider, Account)> {
404        let identifier = id.split(':').collect::<Vec<&str>>();
405        let provider_id = identifier.first()?.parse::<u32>().ok()?;
406        let account_id = identifier.get(1)?.parse::<u32>().ok()?;
407
408        let provider = self.imp().model.find_by_id(provider_id)?;
409        let account = provider.accounts_model().find_by_id(account_id)?;
410
411        Some((provider, account))
412    }
413
414    async fn start_search_provider(&self) {
415        let mut receiver = match start_search_provider().await {
416            Err(err) => {
417                tracing::error!("Failed to start search provider {err}");
418                return;
419            }
420            Ok(receiver) => receiver,
421        };
422        loop {
423            let response = receiver.next().await.unwrap();
424            match response {
425                SearchProviderAction::LaunchSearch(terms) => {
426                    self.activate();
427                    let window = self.active_window();
428                    window.imp().search_entry.set_text(&terms.join(" "));
429                    window.imp().search_btn.set_active(true);
430                    window.present();
431                }
432                SearchProviderAction::ActivateResult(id) => {
433                    let notification = gio::Notification::new(&gettext("One-Time password copied"));
434                    notification.set_body(Some(&gettext("Password was copied successfully")));
435                    self.send_notification(Some(&id), &notification);
436                    let Some((provider, _)) = self.account_provider_by_identifier(&id) else {
437                        return;
438                    };
439                    glib::timeout_add_seconds_local_once(
440                        provider.period(),
441                        glib::clone!(
442                            #[weak(rename_to = app)]
443                            self,
444                            move || {
445                                app.withdraw_notification(&id);
446                            }
447                        ),
448                    );
449                }
450                SearchProviderAction::InitialResultSet(terms, sender) => {
451                    // don't show any results if the application is locked
452                    let response = if self.is_locked() {
453                        vec![]
454                    } else {
455                        self.imp()
456                            .model
457                            .find_accounts(&terms)
458                            .into_iter()
459                            .map(|account| format!("{}:{}", account.provider().id(), account.id()))
460                            .collect::<Vec<_>>()
461                    };
462                    sender.send(response).unwrap();
463                }
464                SearchProviderAction::ResultMetas(identifiers, sender) => {
465                    let metas = identifiers
466                        .iter()
467                        .filter_map(|id| {
468                            self.account_provider_by_identifier(id)
469                                .map(|(provider, account)| {
470                                    ResultMeta::builder(id.to_owned(), &account.name())
471                                        .description(&provider.name())
472                                        .clipboard_text(&account.code().replace(' ', ""))
473                                        .build()
474                                })
475                        })
476                        .collect::<Vec<_>>();
477                    sender.send(metas).unwrap();
478                }
479            }
480        }
481    }
482
483    fn present_error_window(&self) {
484        let dialog = KeyringErrorDialog::new(self);
485        dialog.present();
486    }
487}