diff --git a/data/resources/icons/scalable/apps/org.gnome.Fractal.svg b/data/resources/icons/scalable/apps/org.gnome.Fractal.svg new file mode 100644 index 00000000..4ca612d0 --- /dev/null +++ b/data/resources/icons/scalable/apps/org.gnome.Fractal.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml index 60d8ddd3..e7813411 100644 --- a/data/resources/resources.gresource.xml +++ b/data/resources/resources.gresource.xml @@ -42,6 +42,7 @@ icons/scalable/actions/settings-symbolic.svg icons/scalable/actions/system-search-symbolic.svg icons/scalable/actions/user-add-symbolic.svg + icons/scalable/apps/org.gnome.Fractal.svg icons/scalable/status/audio-symbolic.svg icons/scalable/status/blocked-symbolic.svg icons/scalable/status/checkmark-symbolic.svg diff --git a/po/POTFILES.in b/po/POTFILES.in index 22bfed4d..4e2a061a 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -65,6 +65,7 @@ src/login/advanced_dialog.ui src/login/greeter.ui src/login/homeserver_page.rs src/login/homeserver_page.ui +src/login/in_browser_page.rs src/login/in_browser_page.ui src/login/method_page.rs src/login/method_page.ui diff --git a/src/application.rs b/src/application.rs index 2ccc0c6c..e76c1abc 100644 --- a/src/application.rs +++ b/src/application.rs @@ -20,6 +20,10 @@ use crate::{ /// The key for the current session setting. pub(crate) const SETTINGS_KEY_CURRENT_SESSION: &str = "current-session"; +/// The name of the application. +pub(crate) const APP_NAME: &str = "Fractal"; +/// The URL of the homepage of the application. +pub(crate) const APP_HOMEPAGE_URL: &str = "https://gitlab.gnome.org/World/fractal/"; mod imp { use std::cell::Cell; @@ -231,11 +235,11 @@ mod imp { /// Show the dialog with information about the application. fn show_about_dialog(&self) { let dialog = adw::AboutDialog::builder() - .application_name("Fractal") + .application_name(APP_NAME) .application_icon(config::APP_ID) .developer_name(gettext("The Fractal Team")) .license_type(gtk::License::Gpl30) - .website("https://gitlab.gnome.org/World/fractal/") + .website(APP_HOMEPAGE_URL) .issue_url("https://gitlab.gnome.org/World/fractal/-/issues") .support_url("https://matrix.to/#/#fractal:gnome.org") .version(config::VERSION) diff --git a/src/login/homeserver_page.rs b/src/login/homeserver_page.rs index a77f012a..0db1809a 100644 --- a/src/login/homeserver_page.rs +++ b/src/login/homeserver_page.rs @@ -1,6 +1,6 @@ use adw::{prelude::*, subclass::prelude::*}; use gettextrs::gettext; -use gtk::{self, glib, glib::clone, CompositeTemplate}; +use gtk::{glib, glib::clone, CompositeTemplate}; use matrix_sdk::{ config::RequestConfig, sanitize_server_name, Client, ClientBuildError, ClientBuilder, }; @@ -124,12 +124,17 @@ mod imp { self.update_next_state(); } + /// The current text from the homeserver entry. + pub(super) fn homeserver(&self) -> glib::GString { + self.homeserver_entry.text() + } + /// Whether the current state allows to go to the next step. fn can_go_next(&self) -> bool { let Some(login) = self.login.obj() else { return false; }; - let homeserver = self.homeserver_entry.text(); + let homeserver = self.homeserver(); if login.autodiscovery() { sanitize_server_name(homeserver.as_str()).is_ok() @@ -144,14 +149,9 @@ mod imp { self.next_button.set_sensitive(self.can_go_next()); } - /// Fetch the login details of the homeserver. - #[template_callback] - async fn fetch_homeserver_details(&self) { - self.check_homeserver().await; - } - /// Check if the homeserver that was entered is valid. - pub(super) async fn check_homeserver(&self) { + #[template_callback] + async fn check_homeserver(&self) { if !self.can_go_next() { return; } @@ -164,27 +164,15 @@ mod imp { login.freeze(); let autodiscovery = login.autodiscovery(); - - let res = if autodiscovery { - self.discover_homeserver().await - } else { - self.detect_homeserver().await - }; + let res = self.build_client(autodiscovery).await; match res { Ok(client) => { - let server_name = autodiscovery - .then(|| self.homeserver_entry.text()) - .and_then(|s| sanitize_server_name(&s).ok()); - - login.set_domain(server_name); login.set_client(Some(client.clone())); - - self.homeserver_login_types(client).await; + self.discover_login_api(client).await; } Err(error) => { - let obj = self.obj(); - toast!(obj, error.to_user_facing()); + self.abort_on_error(&error.to_user_facing()); } } @@ -192,9 +180,21 @@ mod imp { login.unfreeze(); } - /// Try to discover the homeserver. - async fn discover_homeserver(&self) -> Result { - let homeserver = self.homeserver_entry.text(); + /// Try to build a client with the current homeserver. + pub(super) async fn build_client( + &self, + autodiscovery: bool, + ) -> Result { + if autodiscovery { + self.build_client_with_autodiscovery().await + } else { + self.build_client_with_url().await + } + } + + /// Try to build a client by using homeserver autodiscovery. + async fn build_client_with_autodiscovery(&self) -> Result { + let homeserver = self.homeserver(); let handle = spawn_tokio!(async move { Self::client_builder() .server_name_or_homeserver_url(homeserver) @@ -211,9 +211,9 @@ mod imp { } } - /// Check if the URL points to a homeserver. - async fn detect_homeserver(&self) -> Result { - let homeserver = self.homeserver_entry.text(); + /// Try to build a client by using the homeserver's URL. + async fn build_client_with_url(&self) -> Result { + let homeserver = self.homeserver(); spawn_tokio!(async move { let client = Self::client_builder() .respect_login_well_known(false) @@ -221,9 +221,9 @@ mod imp { .build() .await?; - // This method calls the `GET /versions` endpoint if it was not called - // previously. - client.unstable_features().await?; + // Call the `GET /versions` endpoint to make sure that the URL belongs to a + // Matrix homeserver. + client.server_versions().await?; Ok(client) }) @@ -231,26 +231,28 @@ mod imp { .expect("task was not aborted") } - /// Fetch the login types supported by the homeserver. - async fn homeserver_login_types(&self, client: Client) { + /// Discover the login API supported by the homeserver. + async fn discover_login_api(&self, client: Client) { let Some(login) = self.login.obj() else { return; }; - let handle = spawn_tokio!(async move { client.matrix_auth().get_login_types().await }); + // Check if the server supports the OAuth 2.0 API. + let oauth = client.oauth(); + let handle = spawn_tokio!(async move { oauth.server_metadata().await }); match handle.await.expect("task was not aborted") { - Ok(res) => { - login.set_login_types(res.flows); - login.show_login_page(); + Ok(_) => { + login.init_oauth_login().await; } Err(error) => { - warn!("Could not get available login types: {error}"); - let obj = self.obj(); - toast!(obj, "Could not get available login types"); - - // Drop the client because it is bound to the homeserver. - login.drop_client(); + if error.is_not_supported() { + // Fallback to the Matrix native API. + login.init_matrix_login().await; + } else { + warn!("Could not get authorization server metadata: {error}"); + self.abort_on_error(&gettext("Could not set up login")); + } } } } @@ -259,6 +261,17 @@ mod imp { fn client_builder() -> ClientBuilder { Client::builder().request_config(RequestConfig::new().retry_limit(2)) } + + /// Show the given error and abort the current login. + fn abort_on_error(&self, error: &str) { + let obj = self.obj(); + toast!(obj, error); + + // Drop the client because it is bound to the homeserver. + if let Some(login) = self.login.obj() { + login.drop_client(); + } + } } } @@ -273,13 +286,21 @@ impl LoginHomeserverPage { glib::Object::new() } + /// The current text from the homeserver entry. + pub(super) fn homeserver(&self) -> glib::GString { + self.imp().homeserver() + } + /// Reset this page. - pub(crate) fn clean(&self) { + pub(super) fn clean(&self) { self.imp().clean(); } - /// Check if the homeserver that was entered is valid. - pub(crate) async fn check_homeserver(&self) { - self.imp().check_homeserver().await; + /// Try to build a client with the current homeserver. + pub(super) async fn build_client( + &self, + autodiscovery: bool, + ) -> Result { + self.imp().build_client(autodiscovery).await } } diff --git a/src/login/homeserver_page.ui b/src/login/homeserver_page.ui index 58e5dbb0..dd9b73b8 100644 --- a/src/login/homeserver_page.ui +++ b/src/login/homeserver_page.ui @@ -71,7 +71,7 @@ false - + homeserver_help @@ -99,7 +99,7 @@ Next center - + - - - LoginMethodPage - - end diff --git a/src/login/mod.rs b/src/login/mod.rs index 4e3c4e13..c6a55be6 100644 --- a/src/login/mod.rs +++ b/src/login/mod.rs @@ -1,16 +1,19 @@ +use std::net::{Ipv4Addr, Ipv6Addr}; + use adw::{prelude::*, subclass::prelude::*}; use gettextrs::gettext; -use gtk::{ - gio, glib, - glib::{clone, closure_local}, - CompositeTemplate, -}; -use matrix_sdk::Client; -use ruma::{ - api::client::session::{get_login_types::v3::LoginType, login}, - OwnedServerName, +use gtk::{gio, glib, glib::clone, CompositeTemplate}; +use matrix_sdk::{ + authentication::oauth::{ + registration::{ApplicationType, ClientMetadata, Localized, OAuthGrantType}, + ClientRegistrationData, + }, + sanitize_server_name, + utils::local_server::{LocalServerBuilder, LocalServerRedirectHandle, LocalServerResponse}, + Client, }; -use tracing::warn; +use ruma::{api::client::session::get_login_types::v3::LoginType, serde::Raw, OwnedServerName}; +use tracing::{error, warn}; use url::Url; mod advanced_dialog; @@ -22,13 +25,17 @@ mod session_setup_view; mod sso_idp_button; use self::{ - advanced_dialog::LoginAdvancedDialog, greeter::Greeter, homeserver_page::LoginHomeserverPage, - in_browser_page::LoginInBrowserPage, method_page::LoginMethodPage, + advanced_dialog::LoginAdvancedDialog, + greeter::Greeter, + homeserver_page::LoginHomeserverPage, + in_browser_page::{LoginInBrowserData, LoginInBrowserPage}, + method_page::LoginMethodPage, session_setup_view::SessionSetupView, }; use crate::{ - components::OfflineBanner, prelude::*, secret::Secret, session::model::Session, spawn, toast, - Application, Window, RUNTIME, SETTINGS_KEY_CURRENT_SESSION, + components::OfflineBanner, prelude::*, secret::Secret, session::model::Session, spawn, + spawn_tokio, toast, Application, Window, APP_HOMEPAGE_URL, APP_NAME, RUNTIME, + SETTINGS_KEY_CURRENT_SESSION, }; /// A page of the login stack. @@ -52,13 +59,9 @@ enum LoginPage { } mod imp { - use std::{ - cell::{Cell, RefCell}, - marker::PhantomData, - sync::LazyLock, - }; + use std::cell::{Cell, RefCell}; - use glib::subclass::{InitializingObject, Signal}; + use glib::subclass::InitializingObject; use super::*; @@ -81,18 +84,6 @@ mod imp { /// Whether auto-discovery is enabled. #[property(get, set = Self::set_autodiscovery, construct, explicit_notify, default = true)] autodiscovery: Cell, - /// The login types supported by the homeserver. - login_types: RefCell>, - /// The domain of the homeserver to log into. - domain: RefCell>, - /// The domain of the homeserver to log into, as a string. - #[property(get = Self::domain_string)] - domain_string: PhantomData>, - /// The URL of the homeserver to log into. - homeserver_url: RefCell>, - /// The URL of the homeserver to log into, as a string. - #[property(get = Self::homeserver_url_string)] - homeserver_url_string: PhantomData>, /// The Matrix client used to log in. client: RefCell>, /// The session that was just logged in. @@ -118,8 +109,8 @@ mod imp { "login.sso", Some(&Option::::static_variant_type()), |obj, _, variant| async move { - let sso_idp_id = variant.and_then(|v| v.get::>()).flatten(); - obj.imp().show_in_browser_page(sso_idp_id, false); + let idp = variant.and_then(|v| v.get::>()).flatten(); + obj.imp().init_matrix_sso_login(idp).await; }, ); @@ -135,21 +126,9 @@ mod imp { #[glib::derived_properties] impl ObjectImpl for Login { - fn signals() -> &'static [Signal] { - static SIGNALS: LazyLock> = LazyLock::new(|| { - vec![ - // The login types changed. - Signal::builder("login-types-changed").build(), - ] - }); - SIGNALS.as_ref() - } - fn constructed(&self) { - let obj = self.obj(); - obj.action_set_enabled("login.next", false); - self.parent_constructed(); + let obj = self.obj(); let monitor = gio::NetworkMonitor::default(); monitor.connect_network_changed(clone!( @@ -255,19 +234,15 @@ mod imp { } // If the client was dropped, try to recreate it. - self.homeserver_page.check_homeserver().await; - if let Some(client) = self.client.borrow().clone() { - return Some(client); - } + let autodiscovery = self.autodiscovery.get(); + let client = self.homeserver_page.build_client(autodiscovery).await.ok(); + self.set_client(client.clone()); - None + client } /// Set the Matrix client. pub(super) fn set_client(&self, client: Option) { - let homeserver = client.as_ref().map(Client::homeserver); - - self.set_homeserver_url(homeserver); self.client.replace(client); } @@ -289,117 +264,161 @@ mod imp { } } - /// Set the domain of the homeserver to log into. - pub(super) fn set_domain(&self, domain: Option) { - if *self.domain.borrow() == domain { + /// Open the login advanced dialog. + async fn open_advanced_dialog(&self) { + let obj = self.obj(); + let dialog = LoginAdvancedDialog::new(); + obj.bind_property("autodiscovery", &dialog, "autodiscovery") + .sync_create() + .bidirectional() + .build(); + dialog.run_future(&*obj).await; + } + + /// Prepare to log in via the OAuth 2.0 API. + pub(super) async fn init_oauth_login(&self) { + let Some(client) = self.client.borrow().clone() else { return; - } + }; - self.domain.replace(domain); - self.obj().notify_domain_string(); - } + let Ok((redirect_uri, local_server_handle)) = self.spawn_local_server().await else { + return; + }; - /// The domain of the homeserver to log into. - /// - /// If autodiscovery is enabled, this is the server name, otherwise, - /// this is the prettified homeserver URL. - fn domain_string(&self) -> Option { - if self.autodiscovery.get() { - self.domain.borrow().clone().map(Into::into) - } else { - self.homeserver_url_string() - } - } + let oauth = client.oauth(); + let handle = spawn_tokio!(async move { + oauth + .login(redirect_uri, None, Some(client_registration_data())) + .build() + .await + }); + + let authorization_data = match handle.await.expect("task was not aborted") { + Ok(authorization_data) => authorization_data, + Err(error) => { + warn!("Could not construct OAuth 2.0 authorization URL: {error}"); + let obj = self.obj(); + toast!(obj, gettext("Could not set up login")); + return; + } + }; - /// The pretty-formatted URL of the homeserver to log into. - fn homeserver_url_string(&self) -> Option { - self.homeserver_url - .borrow() - .as_ref() - .map(|url| url.as_ref().trim_end_matches('/').to_owned()) + self.show_in_browser_page( + local_server_handle, + LoginInBrowserData::Oauth(authorization_data), + ); } - /// Set the URL of the homeserver to log into. - fn set_homeserver_url(&self, homeserver: Option) { - if *self.homeserver_url.borrow() == homeserver { + /// Prepare to log in via the Matrix native API. + pub(super) async fn init_matrix_login(&self) { + let Some(client) = self.client.borrow().clone() else { return; - } + }; - self.homeserver_url.replace(homeserver); + let matrix_auth = client.matrix_auth(); + let handle = spawn_tokio!(async move { matrix_auth.get_login_types().await }); - let obj = self.obj(); - obj.notify_homeserver_url_string(); + let login_types = match handle.await.expect("task was not aborted") { + Ok(response) => response.flows, + Err(error) => { + warn!("Could not get available Matrix login types: {error}"); + let obj = self.obj(); + toast!(obj, gettext("Could not set up login")); + return; + } + }; + + let supports_password = login_types + .iter() + .any(|login_type| matches!(login_type, LoginType::Password(_))); + + if supports_password { + let server_name = self + .autodiscovery + .get() + .then(|| self.homeserver_page.homeserver()) + .and_then(|s| sanitize_server_name(&s).ok()); - if !self.autodiscovery.get() { - obj.notify_domain_string(); + self.show_method_page(&client.homeserver(), server_name.as_ref(), login_types); + } else { + self.init_matrix_sso_login(None).await; } } - /// Set the login types supported by the homeserver. - pub(super) fn set_login_types(&self, types: Vec) { - self.login_types.replace(types); - self.obj().emit_by_name::<()>("login-types-changed", &[]); - } + /// Prepare to log in via the Matrix SSO API. + pub(super) async fn init_matrix_sso_login(&self, idp: Option) { + let Some(client) = self.client.borrow().clone() else { + return; + }; - /// The login types supported by the homeserver. - pub(super) fn login_types(&self) -> Vec { - self.login_types.borrow().clone() - } + let Ok((redirect_uri, local_server_handle)) = self.spawn_local_server().await else { + return; + }; - /// Open the login advanced dialog. - async fn open_advanced_dialog(&self) { - let obj = self.obj(); - let dialog = LoginAdvancedDialog::new(); - obj.bind_property("autodiscovery", &dialog, "autodiscovery") - .sync_create() - .bidirectional() - .build(); - dialog.run_future(&*obj).await; - } + let matrix_auth = client.matrix_auth(); + let handle = spawn_tokio!(async move { + matrix_auth + .get_sso_login_url(redirect_uri.as_str(), idp.as_deref()) + .await + }); - /// Show the appropriate login page given the current login types. - pub(super) fn show_login_page(&self) { - let mut oidc_compatibility = false; - let mut supports_password = false; - - for login_type in self.login_types.borrow().iter() { - match login_type { - LoginType::Sso(sso) if sso.delegated_oidc_compatibility => { - oidc_compatibility = true; - // We do not care about password support at this point. - break; - } - LoginType::Password(_) => { - supports_password = true; - } - _ => {} + match handle.await.expect("task was not aborted") { + Ok(url) => { + let url = Url::parse(&url).expect("Matrix SSO URL should be a valid URL"); + self.show_in_browser_page(local_server_handle, LoginInBrowserData::Matrix(url)); + } + Err(error) => { + warn!("Could not build Matrix SSO URL: {error}"); + let obj = self.obj(); + toast!(obj, gettext("Could not set up login")); } } + } - if oidc_compatibility || !supports_password { - self.show_in_browser_page(None, oidc_compatibility); - } else { - self.navigation.push_by_tag(LoginPage::Method.as_ref()); - } + /// Spawn a local server for listening to redirects. + async fn spawn_local_server(&self) -> Result<(Url, LocalServerRedirectHandle), ()> { + spawn_tokio!(async move { + LocalServerBuilder::new() + .response(local_server_landing_page()) + .spawn() + .await + }) + .await + .expect("task was not aborted") + .map_err(|error| { + warn!("Could not spawn local server: {error}"); + let obj = self.obj(); + toast!(obj, gettext("Could not set up login")); + }) } - /// Show the page to log in with the browser with the given parameters. - fn show_in_browser_page(&self, sso_idp_id: Option, oidc_compatibility: bool) { - self.in_browser_page.set_sso_idp_id(sso_idp_id); - self.in_browser_page - .set_oidc_compatibility(oidc_compatibility); + /// Show the page to chose a login method with the given data. + fn show_method_page( + &self, + homeserver: &Url, + server_name: Option<&OwnedServerName>, + login_types: Vec, + ) { + self.method_page + .update(homeserver, server_name, login_types); + self.navigation.push_by_tag(LoginPage::Method.as_ref()); + } + /// Show the page to log in with the browser with the given data. + fn show_in_browser_page( + &self, + local_server_handle: LocalServerRedirectHandle, + data: LoginInBrowserData, + ) { + self.in_browser_page.set_up(local_server_handle, data); self.navigation.push_by_tag(LoginPage::InBrowser.as_ref()); } - /// Handle the given response after successfully logging in. - pub(super) async fn handle_login_response(&self, response: login::v3::Response) { - let client = self.client().await.expect("client was constructed"); - // The homeserver could have changed with the login response so get it from the - // Client. - let homeserver = client.homeserver(); + /// Create the session after a successful login. + pub(super) async fn create_session(&self) { + let client = self.client().await.expect("client should be constructed"); - match Session::create(homeserver, (&response).into()).await { + match Session::create(&client).await { Ok(session) => { self.init_session(session).await; } @@ -468,9 +487,6 @@ mod imp { // Clean data. self.set_autodiscovery(true); - self.set_login_types(vec![]); - self.set_domain(None); - self.set_homeserver_url(None); self.drop_client(); self.drop_session(); @@ -517,52 +533,161 @@ impl Login { self.imp().drop_client(); } - /// Set the domain of the homeserver to log into. - fn set_domain(&self, domain: Option) { - self.imp().set_domain(domain); + /// Freeze the login screen. + fn freeze(&self) { + self.imp().freeze(); } - /// Set the login types supported by the homeserver. - fn set_login_types(&self, types: Vec) { - self.imp().set_login_types(types); + /// Unfreeze the login screen. + fn unfreeze(&self) { + self.imp().unfreeze(); } - /// The login types supported by the homeserver. - fn login_types(&self) -> Vec { - self.imp().login_types() + /// Prepare to log in via the OAuth 2.0 API. + async fn init_oauth_login(&self) { + self.imp().init_oauth_login().await; } - /// Handle the given response after successfully logging in. - async fn handle_login_response(&self, response: login::v3::Response) { - self.imp().handle_login_response(response).await; + /// Prepare to log in via the Matrix native API. + async fn init_matrix_login(&self) { + self.imp().init_matrix_login().await; } - /// Show the appropriate login screen given the current login types. - fn show_login_page(&self) { - self.imp().show_login_page(); + /// Create the session after a successful login. + async fn create_session(&self) { + self.imp().create_session().await; } +} - /// Freeze the login screen. - fn freeze(&self) { - self.imp().freeze(); - } +/// Client registration data for the OAuth 2.0 API. +fn client_registration_data() -> ClientRegistrationData { + // Register the IPv4 and IPv6 localhost APIs as we use a local server for the + // redirection. + let ipv4_localhost_uri = Url::parse(&format!("http://{}/", Ipv4Addr::LOCALHOST)) + .expect("IPv4 localhost address should be a valid URL"); + let ipv6_localhost_uri = Url::parse(&format!("http://[{}]/", Ipv6Addr::LOCALHOST)) + .expect("IPv6 localhost address should be a valid URL"); + + let client_uri = + Url::parse(APP_HOMEPAGE_URL).expect("application homepage URL should be a valid URL"); + + let mut client_metadata = ClientMetadata::new( + ApplicationType::Native, + vec![OAuthGrantType::AuthorizationCode { + redirect_uris: vec![ipv4_localhost_uri, ipv6_localhost_uri], + }], + Localized::new(client_uri, None), + ); + client_metadata.client_name = Some(Localized::new(APP_NAME.to_owned(), None)); + + Raw::new(&client_metadata) + .expect("client metadata should serialize to JSON successfully") + .into() +} - /// Unfreeze the login screen. - fn unfreeze(&self) { - self.imp().unfreeze(); - } +/// The landing page, after the user performed the authentication and is +/// redirected to the local server. +fn local_server_landing_page() -> LocalServerResponse { + let title = gettext("Authorization Completed"); + let message = gettext( + "The authorization step is complete. You can close this page and go back to Fractal.", + ); + let icon = svg_icon().unwrap_or_default(); + + let css = " + /* Add support for light and dark schemes. */ + :root { + color-scheme: light dark; + } - /// Connect to the signal emitted when the login types changed. - pub fn connect_login_types_changed( - &self, - f: F, - ) -> glib::SignalHandlerId { - self.connect_closure( - "login-types-changed", - true, - closure_local!(move |obj: Self| { - f(&obj); - }), - ) - } + body { + /* Make sure that the page takes all the visible height. */ + height: 100vh; + + /* Cancel default margin in some browsers. */ + margin: 0; + + /* Apply the same colors as libadwaita. */ + color: light-dark(RGB(0 0 6 / 80%), #ffffff); + background-color: light-dark(#ffffff, #1d1d20); + } + + .content { + /* Center the content in the page. */ + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + + /* It looks better if the content is not absolutely vertically + * centered, so we cheat by reducing the height of the container. + */ + height: 80%; + + /* Use the GNOME default font if possible. + * Since Adwaita Sans is based on Inter, use it as a fallback. + */ + font-family: \"Adwaita Sans\", Inter, sans-serif; + + /* Add padding to have space around the text when the window is + * narrow. + */ + padding: 12px; + } + "; + let html = format!( + "\ + + + + + {APP_NAME} - {title} + + + +
+ {icon} +

{title}

+

{message}

+
+ + + " + ); + + LocalServerResponse::Html(html) +} + +/// Get the application SVG icon, ready to be embedded in HTML code. +/// +/// Returns `None` if it failed to be imported. +fn svg_icon() -> Option { + // Load the icon from the application resources. + let Ok(bytes) = gio::resources_lookup_data( + "/org/gnome/Fractal/icons/scalable/apps/org.gnome.Fractal.svg", + gio::ResourceLookupFlags::NONE, + ) else { + error!("Could not find application icon in GResources"); + return None; + }; + + // Convert the bytes to a string, since it should be SVG. + let Ok(icon) = String::from_utf8(bytes.to_vec()) else { + error!("Could not parse application icon as a UTF-8 string"); + return None; + }; + + // Remove the XML prologue, to inline the SVG directly into the HTML. + let Some(stripped_icon) = icon + .trim() + .strip_prefix(r#""#) + else { + error!("Could not strip XML prologue of application icon"); + return None; + }; + + // Wrap the SVG into a div that is hidden in the accessibility tree, since the + // icon is only here for presentation purposes. + Some(format!(r#""#)) } diff --git a/src/secret/linux.rs b/src/secret/linux.rs index 8282f2b3..3b8ceb37 100644 --- a/src/secret/linux.rs +++ b/src/secret/linux.rs @@ -4,6 +4,7 @@ use std::{collections::HashMap, path::Path}; use gettextrs::gettext; +use matrix_sdk::authentication::oauth::ClientId; use oo7::{Item, Keyring}; use ruma::UserId; use serde::Deserialize; @@ -40,6 +41,8 @@ mod keys { pub(super) const DB_PATH: &str = "db-path"; /// The attribute for the session ID. pub(super) const ID: &str = "id"; + /// The attribute for the client ID. + pub(super) const CLIENT_ID: &str = "client-id"; } /// Secret API under Linux. @@ -193,7 +196,7 @@ async fn log_out_session(session: StoredSession, access_token: String) { spawn_tokio!(async move { match matrix::client_with_stored_session(session, tokens).await { Ok(client) => { - if let Err(error) = client.matrix_auth().logout().await { + if let Err(error) = client.logout().await { error!("Could not log out session: {error}"); } } @@ -219,6 +222,7 @@ impl StoredSession { let homeserver = parse_attribute(&attributes, keys::HOMESERVER, Url::parse)?; let user_id = parse_attribute(&attributes, keys::USER, |s| UserId::parse(s))?; let device_id = get_attribute(&attributes, keys::DEVICE_ID)?.as_str().into(); + let id = if version <= 5 { let string = get_attribute(&attributes, keys::DB_PATH)?; Path::new(string) @@ -230,6 +234,9 @@ impl StoredSession { } else { get_attribute(&attributes, keys::ID)?.clone() }; + + let client_id = attributes.get(keys::CLIENT_ID).cloned().map(ClientId::new); + let (passphrase, access_token) = match item.secret().await { Ok(secret) => { if version <= 6 { @@ -275,6 +282,7 @@ impl StoredSession { user_id, device_id, id, + client_id, passphrase: passphrase.into(), }; @@ -292,7 +300,7 @@ impl StoredSession { /// Get the attributes from `self`. fn attributes(&self) -> HashMap<&'static str, String> { - HashMap::from([ + let mut attributes = HashMap::from([ (keys::HOMESERVER, self.homeserver.to_string()), (keys::USER, self.user_id.to_string()), (keys::DEVICE_ID, self.device_id.to_string()), @@ -300,7 +308,13 @@ impl StoredSession { (keys::VERSION, CURRENT_VERSION.to_string()), (keys::PROFILE, PROFILE.to_string()), (keys::XDG_SCHEMA, APP_ID.to_owned()), - ]) + ]); + + if let Some(client_id) = &self.client_id { + attributes.insert(keys::CLIENT_ID, client_id.as_str().to_owned()); + } + + attributes } /// Migrate this session to the current version. diff --git a/src/secret/mod.rs b/src/secret/mod.rs index f48e8f47..3db0b021 100644 --- a/src/secret/mod.rs +++ b/src/secret/mod.rs @@ -3,7 +3,7 @@ use std::{fmt, path::PathBuf}; use gtk::glib; -use matrix_sdk::{SessionMeta, SessionTokens}; +use matrix_sdk::{authentication::oauth::ClientId, Client, SessionMeta, SessionTokens}; use rand::{ distr::{Alphanumeric, SampleString}, rng, @@ -107,6 +107,8 @@ pub struct StoredSession { /// /// This is the name of the directories where the session data lives. pub id: String, + /// The unique identifier of the client with the homeserver. + pub client_id: Option, /// The passphrase used to encrypt the local databases. pub passphrase: Zeroizing, } @@ -123,17 +125,11 @@ impl fmt::Debug for StoredSession { } impl StoredSession { - /// Construct a `StoredSession` from the given SDK user session data. + /// Construct a `StoredSession` from the session of the given Matrix client. /// /// Returns an error if we failed to generate a unique session ID for the /// new session. - pub(crate) async fn new( - homeserver: Url, - meta: SessionMeta, - tokens: SessionTokens, - ) -> Result { - let SessionMeta { user_id, device_id } = meta; - + pub(crate) async fn new(client: &Client) -> Result { // Generate a unique random session ID. let mut id = None; let data_path = data_dir_path(DataType::Persistent); @@ -154,6 +150,17 @@ impl StoredSession { return Err(ClientSetupError::NoSessionId); }; + let homeserver = client.homeserver(); + let SessionMeta { user_id, device_id } = client + .session_meta() + .expect("logged-in client should have session meta") + .clone(); + let tokens = client + .session_tokens() + .expect("logged-in client should have session tokens") + .clone(); + let client_id = client.oauth().client_id().cloned(); + let passphrase = Alphanumeric.sample_string(&mut rng(), PASSPHRASE_LENGTH); let session = Self { @@ -161,6 +168,7 @@ impl StoredSession { user_id, device_id, id, + client_id, passphrase: passphrase.into(), }; diff --git a/src/session/model/session.rs b/src/session/model/session.rs index ab2f97e5..cb870c89 100644 --- a/src/session/model/session.rs +++ b/src/session/model/session.rs @@ -4,8 +4,7 @@ use futures_util::{lock::Mutex, StreamExt}; use gettextrs::gettext; use gtk::{gio, glib, glib::clone, prelude::*, subclass::prelude::*}; use matrix_sdk::{ - authentication::matrix::MatrixSession, config::SyncSettings, media::MediaRetentionPolicy, - sync::SyncResponse, Client, SessionChange, + config::SyncSettings, media::MediaRetentionPolicy, sync::SyncResponse, Client, SessionChange, }; use ruma::{ api::client::{ @@ -17,7 +16,6 @@ use ruma::{ use tokio::task::AbortHandle; use tokio_stream::wrappers::BroadcastStream; use tracing::{debug, error, info}; -use url::Url; use super::{ IgnoredUsers, Notifications, RoomList, SessionSecurity, SessionSettings, SidebarItemList, @@ -386,6 +384,10 @@ mod imp { if let Some(obj) = obj_weak.upgrade() { match change { SessionChange::UnknownToken { .. } => { + info!( + session = obj.session_id(), + "The access token is invalid, cleaning up the session…" + ); obj.imp().clean_up().await; } SessionChange::TokensRefreshed => { @@ -689,12 +691,9 @@ impl Session { .build()) } - /// Create a new session after login. - pub(crate) async fn create( - homeserver: Url, - data: MatrixSession, - ) -> Result { - let stored_session = StoredSession::new(homeserver, data.meta, data.tokens).await?; + /// Create a new session from the session of the given Matrix client. + pub(crate) async fn create(client: &Client) -> Result { + let stored_session = StoredSession::new(client).await?; let settings = Application::default() .session_list() .settings() @@ -731,10 +730,10 @@ impl Session { ); let client = self.client(); - let handle = spawn_tokio!(async move { client.matrix_auth().logout().await }); + let handle = spawn_tokio!(async move { client.logout().await }); match handle.await.expect("task was not aborted") { - Ok(_) => { + Ok(()) => { self.imp().clean_up().await; Ok(()) } diff --git a/src/session/view/account_settings/general_page/log_out_subpage.rs b/src/session/view/account_settings/general_page/log_out_subpage.rs index 4ef4ea39..7bde8daa 100644 --- a/src/session/view/account_settings/general_page/log_out_subpage.rs +++ b/src/session/view/account_settings/general_page/log_out_subpage.rs @@ -131,7 +131,7 @@ impl LogOutSubpage { /// Log out the current session. #[template_callback] - async fn logout(&self) { + async fn log_out(&self) { let Some(session) = self.session() else { return; }; diff --git a/src/session/view/account_settings/general_page/log_out_subpage.ui b/src/session/view/account_settings/general_page/log_out_subpage.ui index 2319092f..6a8ce1e0 100644 --- a/src/session/view/account_settings/general_page/log_out_subpage.ui +++ b/src/session/view/account_settings/general_page/log_out_subpage.ui @@ -85,7 +85,7 @@ Continue - +
@@ -140,7 +140,7 @@ Try Again - + diff --git a/src/session/view/account_settings/general_page/mod.rs b/src/session/view/account_settings/general_page/mod.rs index a5e9726b..b2fac496 100644 --- a/src/session/view/account_settings/general_page/mod.rs +++ b/src/session/view/account_settings/general_page/mod.rs @@ -55,6 +55,8 @@ mod imp { pub user_id: TemplateChild, #[template_child] pub session_id: TemplateChild, + #[template_child] + deactivate_account_button: TemplateChild, /// The current session. #[property(get, set = Self::set_session, nullable)] session: glib::WeakRef, @@ -209,6 +211,11 @@ mod imp { /// Update the possible changes on the user account with the current /// state. fn update_capabilities(&self) { + let Some(session) = self.session.upgrade() else { + return; + }; + + let uses_oauth_api = session.uses_oauth_api(); let has_account_management_url = self.account_management_url_builder().is_some(); let capabilities = self.capabilities.borrow(); @@ -220,6 +227,8 @@ mod imp { .set_visible(!has_account_management_url && capabilities.change_password.enabled); self.manage_account_group .set_visible(has_account_management_url); + self.deactivate_account_button + .set_visible(!uses_oauth_api || has_account_management_url); } /// Open the URL to manage the account. diff --git a/src/session/view/account_settings/general_page/mod.ui b/src/session/view/account_settings/general_page/mod.ui index 84d18778..7db9eee8 100644 --- a/src/session/view/account_settings/general_page/mod.ui +++ b/src/session/view/account_settings/general_page/mod.ui @@ -122,7 +122,7 @@ - + diff --git a/src/session/view/account_settings/user_sessions_page/user_session_subpage.rs b/src/session/view/account_settings/user_sessions_page/user_session_subpage.rs index cbf2edd0..a5def297 100644 --- a/src/session/view/account_settings/user_sessions_page/user_session_subpage.rs +++ b/src/session/view/account_settings/user_sessions_page/user_session_subpage.rs @@ -8,6 +8,7 @@ use super::AccountSettings; use crate::{ components::{ActionButton, ActionState, AuthError, LoadingButtonRow}, gettext_f, + prelude::*, session::model::UserSession, toast, utils::{template_callbacks::TemplateCallbacks, BoundConstructOnlyObject, BoundObject}, @@ -149,10 +150,19 @@ mod imp { self.log_out_button.set_visible(true); self.loading_disconnect_button.set_visible(false); self.open_url_disconnect_button.set_visible(false); - } else if self.account_management_url_builder().is_some() { + return; + } + + let Some(session) = user_session.session() else { + return; + }; + + if session.uses_oauth_api() { + let has_account_management_url = self.account_management_url_builder().is_some(); self.log_out_button.set_visible(false); self.loading_disconnect_button.set_visible(false); - self.open_url_disconnect_button.set_visible(true); + self.open_url_disconnect_button + .set_visible(has_account_management_url); } else { self.log_out_button.set_visible(false); self.loading_disconnect_button.set_visible(true); diff --git a/src/session_list/session_info.rs b/src/session_list/session_info.rs index dc787186..28922841 100644 --- a/src/session_list/session_info.rs +++ b/src/session_list/session_info.rs @@ -1,4 +1,5 @@ use gtk::{glib, prelude::*, subclass::prelude::*}; +use matrix_sdk::authentication::oauth::ClientId; use ruma::{OwnedDeviceId, OwnedUserId}; use url::Url; @@ -117,6 +118,16 @@ pub trait SessionInfoExt: 'static { &self.info().homeserver } + /// The OAuth 2.0 client ID, if any. + fn client_id(&self) -> Option<&ClientId> { + self.info().client_id.as_ref() + } + + /// Whether this session uses the OAuth 2.0 API. + fn uses_oauth_api(&self) -> bool { + self.client_id().is_some() + } + /// The Matrix session's device ID. fn device_id(&self) -> &OwnedDeviceId { &self.info().device_id diff --git a/src/utils/matrix/mod.rs b/src/utils/matrix/mod.rs index b1cad474..de6f4a85 100644 --- a/src/utils/matrix/mod.rs +++ b/src/utils/matrix/mod.rs @@ -5,11 +5,14 @@ use std::{borrow::Cow, fmt, str::FromStr}; use gettextrs::gettext; use gtk::{glib, prelude::*}; use matrix_sdk::{ - authentication::matrix::MatrixSession, + authentication::{ + matrix::MatrixSession, + oauth::{OAuthSession, UserSession}, + }, config::RequestConfig, deserialized_responses::RawAnySyncOrStrippedTimelineEvent, encryption::{BackupDownloadStrategy, EncryptionSettings}, - Client, ClientBuildError, SessionMeta, SessionTokens, + AuthSession, Client, ClientBuildError, SessionMeta, SessionTokens, }; use ruma::{ events::{ @@ -298,12 +301,19 @@ pub async fn client_with_stored_session( user_id, device_id, passphrase, + client_id, .. } = session; - let session_data = MatrixSession { - meta: SessionMeta { user_id, device_id }, - tokens, + let meta = SessionMeta { user_id, device_id }; + let session_data: AuthSession = if let Some(client_id) = client_id { + OAuthSession { + user: UserSession { meta, tokens }, + client_id, + } + .into() + } else { + MatrixSession { meta, tokens }.into() }; let encryption_settings = EncryptionSettings {