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 #[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 #[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 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 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 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 if let Some(uri) = uris.first() {
246 let window = self.obj().active_window();
247 window.open_add_account(Some(uri))
248 }
249 }
250 }
251 impl GtkApplicationImpl for Application {}
254
255 impl AdwApplicationImpl for Application {}
256}
257
258glib::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 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 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 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), ¬ification);
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 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}