Skip to main content

fractal/login/
mod.rs

1use std::net::{Ipv4Addr, Ipv6Addr};
2
3use adw::{prelude::*, subclass::prelude::*};
4use gettextrs::gettext;
5use gtk::{gio, glib, glib::clone};
6use matrix_sdk::{
7    Client,
8    authentication::oauth::{
9        ClientRegistrationData,
10        registration::{ApplicationType, ClientMetadata, Localized, OAuthGrantType},
11    },
12    sanitize_server_name,
13    utils::local_server::LocalServerRedirectHandle,
14};
15use ruma::{OwnedServerName, api::client::session::get_login_types::v3::LoginType, serde::Raw};
16use tracing::warn;
17use url::Url;
18
19mod advanced_dialog;
20mod greeter;
21mod homeserver_page;
22mod in_browser_page;
23mod local_server;
24mod method_page;
25mod session_setup_view;
26
27use self::{
28    advanced_dialog::LoginAdvancedDialog,
29    greeter::Greeter,
30    homeserver_page::LoginHomeserverPage,
31    in_browser_page::{LoginInBrowserData, LoginInBrowserPage},
32    local_server::spawn_local_server,
33    method_page::LoginMethodPage,
34    session_setup_view::SessionSetupView,
35};
36use crate::{
37    APP_HOMEPAGE_URL, APP_NAME, Application, RUNTIME, SETTINGS_KEY_CURRENT_SESSION, Window,
38    components::OfflineBanner, prelude::*, secret::Secret, session::Session, spawn, spawn_tokio,
39    toast,
40};
41
42/// A page of the login stack.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44enum LoginPage {
45    /// The greeter page.
46    Greeter,
47    /// The homeserver page.
48    Homeserver,
49    /// The page to select a login method.
50    Method,
51    /// The page to log in with the browser.
52    InBrowser,
53    /// The session setup stack.
54    SessionSetup,
55    /// The login is completed.
56    Completed,
57}
58
59impl LoginPage {
60    /// Get the tag for this page.
61    const fn tag(self) -> &'static str {
62        match self {
63            Self::Greeter => Greeter::TAG,
64            Self::Homeserver => LoginHomeserverPage::TAG,
65            Self::Method => LoginMethodPage::TAG,
66            Self::InBrowser => LoginInBrowserPage::TAG,
67            Self::SessionSetup => SessionSetupView::TAG,
68            Self::Completed => "completed",
69        }
70    }
71
72    /// Get the page matching the given tag.
73    ///
74    /// Panics if the tag does not match any of the variants.
75    fn from_tag(tag: &str) -> Self {
76        match tag {
77            Greeter::TAG => Self::Greeter,
78            LoginHomeserverPage::TAG => Self::Homeserver,
79            LoginMethodPage::TAG => Self::Method,
80            LoginInBrowserPage::TAG => Self::InBrowser,
81            SessionSetupView::TAG => Self::SessionSetup,
82            "completed" => Self::Completed,
83            _ => panic!("Unknown LoginPage: {tag}"),
84        }
85    }
86}
87
88mod imp {
89    use std::cell::{Cell, RefCell};
90
91    use glib::subclass::InitializingObject;
92
93    use super::*;
94
95    #[derive(Debug, Default, gtk::CompositeTemplate, glib::Properties)]
96    #[template(resource = "/org/gnome/Fractal/ui/login/mod.ui")]
97    #[properties(wrapper_type = super::Login)]
98    pub struct Login {
99        #[template_child]
100        navigation: TemplateChild<adw::NavigationView>,
101        #[template_child]
102        greeter: TemplateChild<Greeter>,
103        #[template_child]
104        homeserver_page: TemplateChild<LoginHomeserverPage>,
105        #[template_child]
106        method_page: TemplateChild<LoginMethodPage>,
107        #[template_child]
108        in_browser_page: TemplateChild<LoginInBrowserPage>,
109        #[template_child]
110        done_button: TemplateChild<gtk::Button>,
111        /// Whether auto-discovery is enabled.
112        #[property(get, set = Self::set_autodiscovery, construct, explicit_notify, default = true)]
113        autodiscovery: Cell<bool>,
114        /// The Matrix client used to log in.
115        client: RefCell<Option<Client>>,
116        /// The session that was just logged in.
117        session: RefCell<Option<Session>>,
118    }
119
120    #[glib::object_subclass]
121    impl ObjectSubclass for Login {
122        const NAME: &'static str = "Login";
123        type Type = super::Login;
124        type ParentType = adw::Bin;
125
126        fn class_init(klass: &mut Self::Class) {
127            OfflineBanner::ensure_type();
128
129            Self::bind_template(klass);
130            Self::bind_template_callbacks(klass);
131
132            klass.set_css_name("login");
133            klass.set_accessible_role(gtk::AccessibleRole::Group);
134
135            klass.install_action_async("login.sso", None, |obj, _, _| async move {
136                obj.imp().init_matrix_sso_login().await;
137            });
138
139            klass.install_action_async("login.open-advanced", None, |obj, _, _| async move {
140                obj.imp().open_advanced_dialog().await;
141            });
142        }
143
144        fn instance_init(obj: &InitializingObject<Self>) {
145            obj.init_template();
146        }
147    }
148
149    #[glib::derived_properties]
150    impl ObjectImpl for Login {
151        fn constructed(&self) {
152            self.parent_constructed();
153            let obj = self.obj();
154
155            let monitor = gio::NetworkMonitor::default();
156            monitor.connect_network_changed(clone!(
157                #[weak]
158                obj,
159                move |_, available| {
160                    obj.action_set_enabled("login.sso", available);
161                }
162            ));
163            obj.action_set_enabled("login.sso", monitor.is_network_available());
164
165            self.navigation.connect_visible_page_notify(clone!(
166                #[weak(rename_to = imp)]
167                self,
168                move |_| {
169                    imp.visible_page_changed();
170                }
171            ));
172        }
173
174        fn dispose(&self) {
175            self.drop_client();
176            self.drop_session();
177        }
178    }
179
180    impl WidgetImpl for Login {
181        fn grab_focus(&self) -> bool {
182            match self.visible_page() {
183                LoginPage::Greeter => self.greeter.grab_focus(),
184                LoginPage::Homeserver => self.homeserver_page.grab_focus(),
185                LoginPage::Method => self.method_page.grab_focus(),
186                LoginPage::InBrowser => self.in_browser_page.grab_focus(),
187                LoginPage::SessionSetup => {
188                    if let Some(session_setup) = self.session_setup() {
189                        session_setup.grab_focus()
190                    } else {
191                        false
192                    }
193                }
194                LoginPage::Completed => self.done_button.grab_focus(),
195            }
196        }
197    }
198
199    impl BinImpl for Login {}
200    impl AccessibleImpl for Login {}
201
202    #[gtk::template_callbacks]
203    impl Login {
204        /// The visible page of the view.
205        pub(super) fn visible_page(&self) -> LoginPage {
206            LoginPage::from_tag(
207                &self
208                    .navigation
209                    .visible_page()
210                    .expect("Login navigation view should always have a visible page")
211                    .tag()
212                    .expect("Login navigation page should always have a tag"),
213            )
214        }
215
216        /// Set whether auto-discovery is enabled.
217        pub fn set_autodiscovery(&self, autodiscovery: bool) {
218            if self.autodiscovery.get() == autodiscovery {
219                return;
220            }
221
222            self.autodiscovery.set(autodiscovery);
223            self.obj().notify_autodiscovery();
224        }
225
226        /// Get the session setup view, if any.
227        pub(super) fn session_setup(&self) -> Option<SessionSetupView> {
228            self.navigation
229                .find_page(LoginPage::SessionSetup.tag())
230                .and_downcast()
231        }
232
233        /// The visible page changed.
234        fn visible_page_changed(&self) {
235            match self.visible_page() {
236                LoginPage::Greeter => {
237                    self.clean();
238                }
239                LoginPage::Homeserver => {
240                    // Drop the client because it is bound to the homeserver.
241                    self.drop_client();
242                    // Drop the session because it is bound to the homeserver and account.
243                    self.drop_session();
244                    self.method_page.clean();
245                }
246                LoginPage::Method => {
247                    // Drop the session because it is bound to the account.
248                    self.drop_session();
249                }
250                _ => {}
251            }
252        }
253
254        /// The Matrix client.
255        pub(super) async fn client(&self) -> Option<Client> {
256            if let Some(client) = self.client.borrow().clone() {
257                return Some(client);
258            }
259
260            // If the client was dropped, try to recreate it.
261            let autodiscovery = self.autodiscovery.get();
262            let client = self.homeserver_page.build_client(autodiscovery).await.ok();
263            self.set_client(client.clone());
264
265            client
266        }
267
268        /// Set the Matrix client.
269        pub(super) fn set_client(&self, client: Option<Client>) {
270            self.client.replace(client);
271        }
272
273        /// Drop the Matrix client.
274        pub(super) fn drop_client(&self) {
275            if let Some(client) = self.client.take() {
276                // The `Client` needs to access a tokio runtime when it is dropped.
277                let _guard = RUNTIME.enter();
278                drop(client);
279            }
280        }
281
282        /// Drop the session and clean up its data from the system.
283        fn drop_session(&self) {
284            if let Some(session) = self.session.take() {
285                spawn!(async move {
286                    let _ = session.log_out().await;
287                });
288            }
289        }
290
291        /// Open the login advanced dialog.
292        async fn open_advanced_dialog(&self) {
293            let obj = self.obj();
294            let dialog = LoginAdvancedDialog::new();
295            obj.bind_property("autodiscovery", &dialog, "autodiscovery")
296                .sync_create()
297                .bidirectional()
298                .build();
299            dialog.run_future(&*obj).await;
300        }
301
302        /// Prepare to log in via the OAuth 2.0 API.
303        pub(super) async fn init_oauth_login(&self) {
304            let Some(client) = self.client.borrow().clone() else {
305                return;
306            };
307
308            let Ok((redirect_uri, local_server_handle)) = spawn_local_server().await else {
309                return;
310            };
311
312            let oauth = client.oauth();
313            let handle = spawn_tokio!(async move {
314                oauth
315                    .login(redirect_uri, None, Some(client_registration_data()), None)
316                    .build()
317                    .await
318            });
319
320            let authorization_data = match handle.await.expect("task was not aborted") {
321                Ok(authorization_data) => authorization_data,
322                Err(error) => {
323                    warn!("Could not construct OAuth 2.0 authorization URL: {error}");
324                    toast!(self.obj(), gettext("Could not set up login"));
325                    return;
326                }
327            };
328
329            self.show_in_browser_page(
330                local_server_handle,
331                LoginInBrowserData::Oauth(authorization_data),
332            );
333        }
334
335        /// Prepare to log in via the Matrix native API.
336        pub(super) async fn init_matrix_login(&self) {
337            let Some(client) = self.client.borrow().clone() else {
338                return;
339            };
340
341            let matrix_auth = client.matrix_auth();
342            let handle = spawn_tokio!(async move { matrix_auth.get_login_types().await });
343
344            let login_types = match handle.await.expect("task was not aborted") {
345                Ok(response) => response.flows,
346                Err(error) => {
347                    warn!("Could not get available Matrix login types: {error}");
348                    toast!(self.obj(), gettext("Could not set up login"));
349                    return;
350                }
351            };
352
353            let supports_password = login_types
354                .iter()
355                .any(|login_type| matches!(login_type, LoginType::Password(_)));
356            let supports_sso = login_types
357                .iter()
358                .any(|login_type| matches!(login_type, LoginType::Sso(_)));
359
360            if supports_password {
361                let server_name = self
362                    .autodiscovery
363                    .get()
364                    .then(|| self.homeserver_page.homeserver())
365                    .and_then(|s| sanitize_server_name(&s).ok());
366
367                self.show_method_page(&client.homeserver(), server_name.as_ref(), supports_sso);
368            } else {
369                self.init_matrix_sso_login().await;
370            }
371        }
372
373        /// Prepare to log in via the Matrix SSO API.
374        pub(super) async fn init_matrix_sso_login(&self) {
375            let Some(client) = self.client.borrow().clone() else {
376                return;
377            };
378
379            let Ok((redirect_uri, local_server_handle)) = spawn_local_server().await else {
380                return;
381            };
382
383            let matrix_auth = client.matrix_auth();
384            let handle = spawn_tokio!(async move {
385                matrix_auth
386                    .get_sso_login_url(redirect_uri.as_str(), None)
387                    .await
388            });
389
390            match handle.await.expect("task was not aborted") {
391                Ok(url) => {
392                    let url = Url::parse(&url).expect("Matrix SSO URL should be a valid URL");
393                    self.show_in_browser_page(local_server_handle, LoginInBrowserData::Matrix(url));
394                }
395                Err(error) => {
396                    warn!("Could not build Matrix SSO URL: {error}");
397                    toast!(self.obj(), gettext("Could not set up login"));
398                }
399            }
400        }
401
402        /// Show the page to chose a login method with the given data.
403        fn show_method_page(
404            &self,
405            homeserver: &Url,
406            server_name: Option<&OwnedServerName>,
407            supports_sso: bool,
408        ) {
409            self.method_page
410                .update(homeserver, server_name, supports_sso);
411            self.navigation.push_by_tag(LoginPage::Method.tag());
412        }
413
414        /// Show the page to log in with the browser with the given data.
415        fn show_in_browser_page(
416            &self,
417            local_server_handle: LocalServerRedirectHandle,
418            data: LoginInBrowserData,
419        ) {
420            self.in_browser_page.set_up(local_server_handle, data);
421            self.navigation.push_by_tag(LoginPage::InBrowser.tag());
422        }
423
424        /// Create the session after a successful login.
425        pub(super) async fn create_session(&self) {
426            let client = self.client().await.expect("client should be constructed");
427
428            match Session::create(&client).await {
429                Ok(session) => {
430                    self.init_session(session).await;
431                }
432                Err(error) => {
433                    warn!("Could not create session: {error}");
434                    toast!(self.obj(), error.to_user_facing());
435
436                    self.navigation.pop();
437                }
438            }
439        }
440
441        /// Initialize the given session.
442        async fn init_session(&self, session: Session) {
443            let setup_view = SessionSetupView::new(&session);
444            setup_view.connect_completed(clone!(
445                #[weak(rename_to = imp)]
446                self,
447                move |_| {
448                    imp.navigation.push_by_tag(LoginPage::Completed.tag());
449                }
450            ));
451            self.navigation.push(&setup_view);
452
453            self.drop_client();
454            self.session.replace(Some(session.clone()));
455
456            // Save ID of logging in session to GSettings
457            let settings = Application::default().settings();
458            if let Err(err) =
459                settings.set_string(SETTINGS_KEY_CURRENT_SESSION, session.session_id())
460            {
461                warn!("Could not save current session: {err}");
462            }
463
464            let session_info = session.info().clone();
465
466            if Secret::store_session(session_info).await.is_err() {
467                toast!(self.obj(), gettext("Could not store session"));
468            }
469
470            session.prepare().await;
471        }
472
473        /// Finish the login process and show the session.
474        #[template_callback]
475        fn finish_login(&self) {
476            let Some(window) = self.obj().root().and_downcast::<Window>() else {
477                return;
478            };
479
480            if let Some(session) = self.session.take() {
481                window.add_session(session);
482            }
483
484            self.clean();
485        }
486
487        /// Reset the login stack.
488        pub(super) fn clean(&self) {
489            // Clean pages.
490            self.homeserver_page.clean();
491            self.method_page.clean();
492
493            // Clean data.
494            self.set_autodiscovery(true);
495            self.drop_client();
496            self.drop_session();
497
498            // Reinitialize UI.
499            self.navigation.pop_to_tag(LoginPage::Greeter.tag());
500            self.unfreeze();
501        }
502
503        /// Freeze the login screen.
504        pub(super) fn freeze(&self) {
505            self.navigation.set_sensitive(false);
506        }
507
508        /// Unfreeze the login screen.
509        pub(super) fn unfreeze(&self) {
510            self.navigation.set_sensitive(true);
511        }
512    }
513}
514
515glib::wrapper! {
516    /// A widget managing the login flows.
517    pub struct Login(ObjectSubclass<imp::Login>)
518        @extends gtk::Widget, adw::Bin,
519        @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget;
520}
521
522impl Login {
523    pub fn new() -> Self {
524        glib::Object::new()
525    }
526
527    /// Set the Matrix client.
528    fn set_client(&self, client: Option<Client>) {
529        self.imp().set_client(client);
530    }
531
532    /// The Matrix client.
533    async fn client(&self) -> Option<Client> {
534        self.imp().client().await
535    }
536
537    /// Drop the Matrix client.
538    fn drop_client(&self) {
539        self.imp().drop_client();
540    }
541
542    /// Freeze the login screen.
543    fn freeze(&self) {
544        self.imp().freeze();
545    }
546
547    /// Unfreeze the login screen.
548    fn unfreeze(&self) {
549        self.imp().unfreeze();
550    }
551
552    /// Prepare to log in via the OAuth 2.0 API.
553    async fn init_oauth_login(&self) {
554        self.imp().init_oauth_login().await;
555    }
556
557    /// Prepare to log in via the Matrix native API.
558    async fn init_matrix_login(&self) {
559        self.imp().init_matrix_login().await;
560    }
561
562    /// Create the session after a successful login.
563    async fn create_session(&self) {
564        self.imp().create_session().await;
565    }
566}
567
568/// Client registration data for the OAuth 2.0 API.
569fn client_registration_data() -> ClientRegistrationData {
570    // Register the IPv4 and IPv6 localhost APIs as we use a local server for the
571    // redirection.
572    let ipv4_localhost_uri = Url::parse(&format!("http://{}/", Ipv4Addr::LOCALHOST))
573        .expect("IPv4 localhost address should be a valid URL");
574    let ipv6_localhost_uri = Url::parse(&format!("http://[{}]/", Ipv6Addr::LOCALHOST))
575        .expect("IPv6 localhost address should be a valid URL");
576
577    let client_uri =
578        Url::parse(APP_HOMEPAGE_URL).expect("application homepage URL should be a valid URL");
579
580    let mut client_metadata = ClientMetadata::new(
581        ApplicationType::Native,
582        vec![OAuthGrantType::AuthorizationCode {
583            redirect_uris: vec![ipv4_localhost_uri, ipv6_localhost_uri],
584        }],
585        Localized::new(client_uri, None),
586    );
587    client_metadata.client_name = Some(Localized::new(APP_NAME.to_owned(), None));
588
589    Raw::new(&client_metadata)
590        .expect("client metadata should serialize to JSON successfully")
591        .into()
592}