Browse Source

Add support for logging in with the OAuth 2.0 API

af/unable-to-decryt-styling
Kévin Commaille 12 months ago
parent
commit
b577acb584
No known key found for this signature in database
GPG Key ID: C971D9DBC9D678D
  1. 55
      data/resources/icons/scalable/apps/org.gnome.Fractal.svg
  2. 1
      data/resources/resources.gresource.xml
  3. 1
      po/POTFILES.in
  4. 8
      src/application.rs
  5. 119
      src/login/homeserver_page.rs
  6. 4
      src/login/homeserver_page.ui
  7. 255
      src/login/in_browser_page.rs
  8. 2
      src/login/in_browser_page.ui
  9. 103
      src/login/method_page.rs
  10. 10
      src/login/method_page.ui
  11. 483
      src/login/mod.rs
  12. 20
      src/secret/linux.rs
  13. 26
      src/secret/mod.rs
  14. 21
      src/session/model/session.rs
  15. 2
      src/session/view/account_settings/general_page/log_out_subpage.rs
  16. 4
      src/session/view/account_settings/general_page/log_out_subpage.ui
  17. 9
      src/session/view/account_settings/general_page/mod.rs
  18. 2
      src/session/view/account_settings/general_page/mod.ui
  19. 14
      src/session/view/account_settings/user_sessions_page/user_session_subpage.rs
  20. 11
      src/session_list/session_info.rs
  21. 20
      src/utils/matrix/mod.rs

55
data/resources/icons/scalable/apps/org.gnome.Fractal.svg

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg height="128px" viewBox="0 0 128 128" width="128px" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<linearGradient id="a" gradientUnits="userSpaceOnUse" x1="8" x2="58" y1="69.999985" y2="69.999985">
<stop offset="0" stop-color="#4aaac9"/>
<stop offset="0.16" stop-color="#8bddf7"/>
<stop offset="0.32" stop-color="#4aaac9"/>
<stop offset="1" stop-color="#4aaac9"/>
</linearGradient>
<linearGradient id="b" gradientUnits="userSpaceOnUse" x1="31.462524" x2="39" y1="113.997253" y2="113.997253">
<stop offset="0" stop-color="#4aaac9"/>
<stop offset="0.469318" stop-color="#74d7f7"/>
<stop offset="1" stop-color="#4aaac9"/>
</linearGradient>
<linearGradient id="c" gradientUnits="userSpaceOnUse" x1="104" x2="120" y1="84" y2="84">
<stop offset="0" stop-color="#1a5fb4"/>
<stop offset="0.5" stop-color="#4296ff"/>
<stop offset="1" stop-color="#1a5fb4"/>
</linearGradient>
<clipPath id="d">
<path d="m 8 24 h 97 v 84 h -97 z m 0 0"/>
</clipPath>
<clipPath id="e">
<path d="m 24 24 h 80 c 8.835938 0 16 7.164062 16 16 v 52 c 0 8.835938 -7.164062 16 -16 16 h -80 c -8.835938 0 -16 -7.164062 -16 -16 v -52 c 0 -8.835938 7.164062 -16 16 -16 z m 0 0"/>
</clipPath>
<linearGradient id="f" gradientUnits="userSpaceOnUse" x1="55.608135" x2="71.783539" y1="100" y2="48.532928">
<stop offset="0" stop-color="#81dffe"/>
<stop offset="1" stop-color="#9bf8fe"/>
</linearGradient>
<filter id="g" height="100%" width="100%" x="0%" y="0%">
<feColorMatrix in="SourceGraphic" type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
</filter>
<mask id="h">
<g filter="url(#g)">
<rect fill-opacity="0.35" height="128" width="128"/>
</g>
</mask>
<clipPath id="i">
<rect height="152" width="192"/>
</clipPath>
<path d="m 24 28 h 72 c 8.835938 0 16 7.164062 16 16 v 52 c 0 8.835938 -7.164062 16 -16 16 h -72 c -8.835938 0 -16 -7.164062 -16 -16 v -52 c 0 -8.835938 7.164062 -16 16 -16 z m 0 0" fill="url(#a)"/>
<path d="m 24 28 h 80 c 8.835938 0 16 7.164062 16 16 v 48 c 0 8.835938 -7.164062 16 -16 16 h -80 c -8.835938 0 -16 -7.164062 -16 -16 v -48 c 0 -8.835938 7.164062 -16 16 -16 z m 0 0" fill="#53bde0"/>
<path d="m 24 100 v 12 h 4 c 2.210938 0 4 1.789062 4 4 v 7 c 0 1.992188 1.183594 3.792969 3.011719 4.585938 c 1.828125 0.789062 3.953125 0.417968 5.40625 -0.945313 l 13.523437 -12.707031 c 1.324219 -1.242188 3.070313 -1.933594 4.882813 -1.933594 h 9.175781 v -12 z m 0 0" fill="url(#b)" fill-rule="evenodd"/>
<path d="m 102 58.566406 h 2 c 8.835938 0 16 7.164063 16 16 v 21.433594 c 0 8.835938 -7.164062 16 -16 16 h -2 c -8.835938 0 -16 -7.164062 -16 -16 v -21.433594 c 0 -8.835937 7.164062 -16 16 -16 z m 0 0" fill="url(#c)"/>
<path d="m 86 87 h 18 v 25 h -18 z m 0 0" fill="#1a5fb4"/>
<path d="m 48 24 h 56 c 8.835938 0 16 7.164062 16 16 v 52 c 0 8.835938 -7.164062 16 -16 16 h -56 c -8.835938 0 -16 -7.164062 -16 -16 v -52 c 0 -8.835938 7.164062 -16 16 -16 z m 0 0" fill="#3584e4"/>
<g clip-path="url(#d)">
<g clip-path="url(#e)">
<path d="m 78.804688 16.023438 l 0.527343 2.460937 c -1.207031 -0.082031 -2.417969 4.964844 -3.621093 4.988281 c -19.335938 0.371094 -38.003907 14.230469 -39.148438 34.546875 c -0.835938 14.761719 9.570312 29.839844 25.15625 30.488281 c 10.371094 0.433594 20.96875 -6.957031 21.242188 -17.925781 c 0.179687 -7.078125 -4.953126 -14.3125 -12.488282 -14.355469 c -4.683594 -0.027343 -9.484375 3.425782 -9.398437 8.429688 c 0.074219 2.980469 2.300781 6.042969 5.511719 5.902344 c 1.8125 -0.082032 3.691406 -1.488282 3.539062 -3.453125 c -0.078125 -1.042969 -0.921875 -2.128907 -2.0625 -1.996094 c -0.5625 0.066406 -1.148438 0.539063 -1.046875 1.15625 c 0.070313 0.273437 0.285156 0.570313 0.597656 0.5 c 0.121094 -0.03125 0.25 -0.144531 0.214844 -0.28125 c 0 -0.042969 -0.070313 -0.09375 -0.113281 -0.074219 c 0 0.003906 -0.070313 0.023438 0 0.035156 v 0.007813 v -0.003906 v 0.035156 c 0 0.050781 -0.09375 0.050781 -0.136719 0.03125 c -0.121094 -0.066406 -0.113281 -0.242187 -0.070313 -0.347656 c 0.164063 -0.265625 0.542969 -0.230469 0.777344 -0.074219 c 0.519532 0.367188 0.429688 1.117188 0.070313 1.558594 c -0.710938 0.898437 -2.074219 0.726562 -2.867188 0.042968 c -1.5 -1.28125 -1.167969 -3.601562 0.070313 -4.941406 c 2.167968 -2.367187 5.929687 -1.792968 8.074218 0.28125 c 3.601563 3.476563 2.652344 9.308594 -0.675781 12.597656 c -5.359375 5.292969 -14.109375 3.800782 -18.992187 -1.324218 c -7.570313 -7.953125 -5.304688 -20.664063 2.335937 -27.6875 c 11.480469 -10.550782 29.507813 -7.242188 39.363281 3.785156 c 14.414063 16.121094 9.6875 41.066406 -5.855468 54.582031 c -11.121094 9.226563 -22.246094 15.429688 -32.949219 19.4375 c -13.058594 75.445313 -75.230469 6.835938 -81.039063 -4.195312 l 0.285157 -105.054688 z m 0 0" fill="url(#f)"/>
</g>
</g>
<path d="m 24 106 v 2 h 4 c 2.210938 0 4 1.789062 4 4 v 7 c 0 1.992188 1.183594 3.792969 3.011719 4.585938 c 1.828125 0.789062 3.953125 0.417968 5.40625 -0.945313 l 13.523437 -12.707031 c 1.324219 -1.242188 3.070313 -1.933594 4.882813 -1.933594 h 9.175781 v -2 z m 0 0" fill="#81dffe" fill-rule="evenodd"/>
<g clip-path="url(#i)" mask="url(#h)" transform="matrix(1 0 0 1 -8 -16)">
<path d="m 173 17 h 8 c 1.65625 0 3 1.34375 3 3 v 7 c 0 1.65625 -1.34375 3 -3 3 h -8 c -1.65625 0 -3 -1.34375 -3 -3 v -7 c 0 -1.65625 1.34375 -3 3 -3 z m 0 0" fill="#241f31"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

1
data/resources/resources.gresource.xml

@ -42,6 +42,7 @@
<file preprocess="xml-stripblanks">icons/scalable/actions/settings-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/actions/system-search-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/actions/user-add-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/apps/org.gnome.Fractal.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/audio-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/blocked-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/status/checkmark-symbolic.svg</file>

1
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

8
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)

119
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<Client, ClientBuildError> {
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<Client, ClientBuildError> {
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<Client, ClientBuildError> {
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<Client, ClientBuildError> {
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<Client, ClientBuildError> {
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<Client, ClientBuildError> {
self.imp().build_client(autodiscovery).await
}
}

4
src/login/homeserver_page.ui

@ -71,7 +71,7 @@
<object class="AdwEntryRow" id="homeserver_entry">
<property name="selectable">false</property>
<signal name="changed" handler="update_next_state" swapped="yes"/>
<signal name="entry-activated" handler="fetch_homeserver_details" swapped="yes"/>
<signal name="entry-activated" handler="check_homeserver" swapped="yes"/>
<accessibility>
<relation name="described-by">homeserver_help</relation>
</accessibility>
@ -99,7 +99,7 @@
<object class="LoadingButton" id="next_button">
<property name="content-label" translatable="yes">Next</property>
<property name="halign">center</property>
<signal name="clicked" handler="fetch_homeserver_details" swapped="yes"/>
<signal name="clicked" handler="check_homeserver" swapped="yes"/>
<style>
<class name="suggested-action"/>
<class name="standalone-button"/>

255
src/login/in_browser_page.rs

@ -1,14 +1,20 @@
use adw::{prelude::*, subclass::prelude::*};
use gettextrs::gettext;
use gtk::{glib, CompositeTemplate};
use ruma::api::client::session::SsoRedirectOidcAction;
use matrix_sdk::{
authentication::oauth::{OAuthAuthorizationData, UrlOrQuery},
utils::local_server::{LocalServerRedirectHandle, QueryString},
Error,
};
use tokio::task::AbortHandle;
use tracing::{error, warn};
use url::Url;
use super::Login;
use crate::{prelude::*, spawn, spawn_tokio, toast};
use crate::{prelude::*, spawn_tokio, toast, APP_NAME};
mod imp {
use std::cell::{Cell, RefCell};
use std::cell::RefCell;
use glib::subclass::InitializingObject;
@ -23,12 +29,12 @@ mod imp {
/// The ancestor `Login` object.
#[property(get, set, nullable)]
login: glib::WeakRef<Login>,
/// Whether we are logging in with OAuth 2.0 compatibility.
#[property(get, set)]
oidc_compatibility: Cell<bool>,
/// The identity provider to use when logging in with SSO.
#[property(get, set, nullable)]
sso_idp_id: RefCell<Option<String>>,
/// A handle to the local server to wait for the redirect.
local_server_handle: RefCell<Option<LocalServerRedirectHandle>>,
/// The login data to use.
data: RefCell<Option<LoginInBrowserData>>,
/// The abort handle for the ongoing task.
abort_handle: RefCell<Option<AbortHandle>>,
}
#[glib::object_subclass]
@ -60,80 +66,182 @@ mod imp {
fn shown(&self) {
self.grab_focus();
}
fn hidden(&self) {
self.clean();
}
}
#[gtk::template_callbacks]
impl LoginInBrowserPage {
/// Open the URL of the SSO login page.
/// Set up this page with the given local server and data.
pub(super) fn set_up(
&self,
local_server_handle: LocalServerRedirectHandle,
data: LoginInBrowserData,
) {
self.clean();
self.local_server_handle.replace(Some(local_server_handle));
self.data.replace(Some(data));
}
/// Open the URL for the current state.
#[template_callback]
async fn login_with_sso(&self) {
async fn launch_url(&self) {
let Some(data) = self.data.borrow().clone() else {
return;
};
if let Err(error) = gtk::UriLauncher::new(data.url().as_str())
.launch_future(self.obj().root().and_downcast_ref::<gtk::Window>())
.await
{
error!("Could not launch URI: {error}");
let obj = self.obj();
toast!(obj, gettext("Could not open URL"));
return;
}
let Some(local_server_handle) = self.local_server_handle.take() else {
// If we don't have the server handle, we are already waiting for the redirect.
return;
};
let handle = spawn_tokio!(async move { local_server_handle.await });
self.abort_handle.replace(Some(handle.abort_handle()));
let Ok(result) = handle.await else {
// The task was aborted.
self.abort_handle.take();
return;
};
self.abort_handle.take();
if let Some(window) = self.obj().root().and_downcast::<gtk::Window>() {
window.present();
}
let Some(query_string) = result else {
warn!("Could not log in: missing query string in redirect URI");
self.abort_on_error(&gettext("An unexpected error occurred."));
return;
};
match data {
LoginInBrowserData::Oauth(_) => self.finish_oauth_login(query_string).await,
LoginInBrowserData::Matrix(url) => {
self.finish_matrix_login(url, query_string).await;
}
}
}
/// Finish the OAuth 2.0 login process.
async fn finish_oauth_login(&self, query_string: QueryString) {
let Some(login) = self.login.upgrade() else {
return;
};
let client = login.client().await.expect("client was constructed");
let oidc_compatibility = self.oidc_compatibility.get();
let sso_idp_id = self.sso_idp_id.borrow().clone();
let client = login
.client()
.await
.expect("login client should be constructed");
let oauth = client.oauth();
let handle =
spawn_tokio!(
async move { oauth.finish_login(UrlOrQuery::Query(query_string.0)).await }
);
let handle = spawn_tokio!(async move {
let mut sso_login = client
.matrix_auth()
.login_sso(|sso_url| async move {
let ctx = glib::MainContext::default();
ctx.spawn(async move {
spawn!(async move {
let mut sso_url = sso_url;
if oidc_compatibility {
if let Ok(mut parsed_url) = Url::parse(&sso_url) {
// Add an action query parameter manually.
parsed_url.query_pairs_mut().append_pair(
"action",
SsoRedirectOidcAction::Login.as_str(),
);
sso_url = parsed_url.into();
} else {
// If parsing fails, just use the provided URL.
error!("Failed to parse SSO URL: {sso_url}");
}
}
if let Err(error) = gtk::UriLauncher::new(&sso_url)
.launch_future(gtk::Window::NONE)
.await
{
// FIXME: We should forward the error.
error!("Could not launch URI: {error}");
}
});
});
Ok(())
})
.initial_device_display_name("Fractal");
if let Some(sso_idp_id) = &sso_idp_id {
sso_login = sso_login.identity_provider_id(sso_idp_id);
self.abort_handle.replace(Some(handle.abort_handle()));
let Ok(result) = handle.await else {
// The task was aborted.
self.abort_handle.take();
return;
};
self.abort_handle.take();
match result {
Ok(()) => {
login.create_session().await;
}
Err(error) => {
warn!("Could not log in via OAuth 2.0: {error}");
self.abort_on_error(&error.to_user_facing());
}
}
}
/// Finish the Matrix SSO login process.
async fn finish_matrix_login(&self, mut url: Url, query_string: QueryString) {
let Some(login) = self.login.upgrade() else {
return;
};
sso_login.send().await
let client = login
.client()
.await
.expect("login client should be constructed");
let matrix_auth = client.matrix_auth();
// We need to rebuild the URL to use the SDK's method.
url.set_query(Some(&query_string.0));
let handle = spawn_tokio!(async move {
matrix_auth
.login_with_sso_callback(url)
.map_err(|error| Error::UnknownError(error.into()))?
.initial_device_display_name(APP_NAME)
.await
});
match handle.await.expect("task was not aborted") {
Ok(response) => {
login.handle_login_response(response).await;
self.abort_handle.replace(Some(handle.abort_handle()));
let Ok(result) = handle.await else {
// The task was aborted.
self.abort_handle.take();
return;
};
self.abort_handle.take();
match result {
Ok(_) => {
login.create_session().await;
}
Err(error) => {
warn!("Could not log in via SSO: {error}");
let obj = self.obj();
toast!(obj, error.to_user_facing());
self.abort_on_error(&error.to_user_facing());
}
}
}
/// Show the given error and abort the current login.
fn abort_on_error(&self, error: &str) {
let obj = self.obj();
toast!(obj, error);
// We need to restart the server if the user wants to try again, so let's go
// back to the previous screen.
let _ = self.obj().activate_action("navigation.pop", None);
}
/// Reset this page.
fn clean(&self) {
if let Some(handle) = self.abort_handle.take() {
handle.abort();
}
self.data.take();
self.local_server_handle.take();
}
}
}
glib::wrapper! {
/// A page shown while the user is logging in via SSO.
/// A page to log the user in via the browser.
pub struct LoginInBrowserPage(ObjectSubclass<imp::LoginInBrowserPage>)
@extends gtk::Widget, adw::NavigationPage, @implements gtk::Accessible;
}
@ -142,4 +250,33 @@ impl LoginInBrowserPage {
pub fn new() -> Self {
glib::Object::new()
}
/// Set up this page with the given local server and data.
pub(super) fn set_up(
&self,
local_server_handle: LocalServerRedirectHandle,
data: LoginInBrowserData,
) {
self.imp().set_up(local_server_handle, data);
}
}
/// Data for logging in via the browser.
#[derive(Debug, Clone)]
pub(super) enum LoginInBrowserData {
/// Log in via the OAuth 2.0 API with the given authorization data.
Oauth(OAuthAuthorizationData),
/// Log in via the Matrix native SSO API with the given URL.
Matrix(Url),
}
impl LoginInBrowserData {
/// Get the URL to open in the browser.
fn url(&self) -> &Url {
match self {
Self::Oauth(authorization_data) => &authorization_data.url,
Self::Matrix(url) => url,
}
}
}

2
src/login/in_browser_page.ui

@ -87,7 +87,7 @@
</object>
</property>
<property name="halign">center</property>
<signal name="clicked" handler="login_with_sso" swapped="yes" />
<signal name="clicked" handler="launch_url" swapped="yes" />
</object>
</child>
</object>

103
src/login/method_page.rs

@ -1,13 +1,12 @@
use adw::{prelude::*, subclass::prelude::*};
use gettextrs::gettext;
use gtk::{self, glib, glib::clone, CompositeTemplate};
use ruma::api::client::session::get_login_types::v3::LoginType;
use gtk::{self, glib, CompositeTemplate};
use ruma::{api::client::session::get_login_types::v3::LoginType, OwnedServerName};
use tracing::warn;
use url::Url;
use super::{sso_idp_button::SsoIdpButton, Login};
use crate::{
components::LoadingButton, gettext_f, prelude::*, spawn_tokio, toast, utils::BoundObjectWeakRef,
};
use crate::{components::LoadingButton, gettext_f, prelude::*, spawn_tokio, toast};
mod imp {
use std::cell::RefCell;
@ -23,6 +22,8 @@ mod imp {
#[template_child]
title: TemplateChild<gtk::Label>,
#[template_child]
homeserver_url: TemplateChild<gtk::Label>,
#[template_child]
username_entry: TemplateChild<adw::EntryRow>,
#[template_child]
password_entry: TemplateChild<adw::PasswordEntryRow>,
@ -34,8 +35,8 @@ mod imp {
#[template_child]
next_button: TemplateChild<LoadingButton>,
/// The parent `Login` object.
#[property(get, set = Self::set_login, nullable)]
login: BoundObjectWeakRef<Login>,
#[property(get, set, nullable)]
login: glib::WeakRef<Login>,
}
#[glib::object_subclass]
@ -71,35 +72,6 @@ mod imp {
#[gtk::template_callbacks]
impl LoginMethodPage {
/// Set the parent `Login` object.
fn set_login(&self, login: Option<&Login>) {
self.login.disconnect_signals();
if let Some(login) = login {
let domain_handler = login.connect_domain_string_notify(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_domain_name();
}
));
let login_types_handler = login.connect_login_types_changed(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_sso();
}
));
self.login
.set(login, vec![domain_handler, login_types_handler]);
}
self.update_domain_name();
self.update_sso();
self.update_next_state();
}
/// The username entered by the user.
fn username(&self) -> glib::GString {
self.username_entry.text()
@ -110,35 +82,33 @@ mod imp {
self.password_entry.text()
}
/// Update the domain name displayed in the title.
fn update_domain_name(&self) {
let Some(login) = self.login.obj() else {
return;
};
let title = &self.title;
if let Some(domain) = login.domain_string() {
title.set_markup(&gettext_f(
/// Update the domain name and URL displayed in the title.
pub(super) fn update_title(
&self,
homeserver_url: &Url,
server_name: Option<&OwnedServerName>,
) {
let title = if let Some(server_name) = server_name {
gettext_f(
// Translators: Do NOT translate the content between '{' and '}', this is a
// variable name.
"Log in to {domain_name}",
&[(
"domain_name",
&format!("<span segment=\"word\">{domain}</span>"),
&format!("<span segment=\"word\">{server_name}</span>"),
)],
));
)
} else {
title.set_markup(&gettext("Log in"));
}
gettext("Log in")
};
self.title.set_markup(&title);
let homeserver_url = homeserver_url.as_str().trim_end_matches('/');
self.homeserver_url.set_label(homeserver_url);
}
/// Update the SSO group.
fn update_sso(&self) {
let Some(login) = self.login.obj() else {
return;
};
let login_types = login.login_types();
pub(super) fn update_sso(&self, login_types: Vec<LoginType>) {
let Some(sso_login) = login_types.into_iter().find_map(|t| match t {
LoginType::Sso(sso) => Some(sso),
_ => None,
@ -188,7 +158,7 @@ mod imp {
/// Update the state of the "Next" button.
#[template_callback]
fn update_next_state(&self) {
pub(super) fn update_next_state(&self) {
self.next_button
.set_sensitive(self.can_login_with_password());
}
@ -200,7 +170,7 @@ mod imp {
return;
}
let Some(login) = self.login.obj() else {
let Some(login) = self.login.upgrade() else {
return;
};
@ -220,9 +190,9 @@ mod imp {
.await
});
match handle.await.unwrap() {
Ok(response) => {
login.handle_login_response(response).await;
match handle.await.expect("task was not aborted") {
Ok(_) => {
login.create_session().await;
}
Err(error) => {
warn!("Could not log in: {error}");
@ -264,6 +234,19 @@ impl LoginMethodPage {
glib::Object::new()
}
/// Update this page with the given data.
pub(crate) fn update(
&self,
homeserver_url: &Url,
domain_name: Option<&OwnedServerName>,
login_types: Vec<LoginType>,
) {
let imp = self.imp();
imp.update_title(homeserver_url, domain_name);
imp.update_sso(login_types);
imp.update_next_state();
}
/// Reset this page.
pub(crate) fn clean(&self) {
self.imp().clean();

10
src/login/method_page.ui

@ -60,11 +60,6 @@
<object class="GtkBox">
<property name="spacing">6</property>
<property name="halign">center</property>
<binding name="visible">
<lookup name="autodiscovery">
<lookup name="login">LoginMethodPage</lookup>
</lookup>
</binding>
<property name="tooltip-text" translatable="yes">Homeserver URL</property>
<child>
<object class="GtkImage">
@ -77,11 +72,6 @@
<style>
<class name="body"/>
</style>
<binding name="label">
<lookup name="homeserver-url-string">
<lookup name="login">LoginMethodPage</lookup>
</lookup>
</binding>
<property name="ellipsize">end</property>
</object>
</child>

483
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<bool>,
/// The login types supported by the homeserver.
login_types: RefCell<Vec<LoginType>>,
/// The domain of the homeserver to log into.
domain: RefCell<Option<OwnedServerName>>,
/// The domain of the homeserver to log into, as a string.
#[property(get = Self::domain_string)]
domain_string: PhantomData<Option<String>>,
/// The URL of the homeserver to log into.
homeserver_url: RefCell<Option<Url>>,
/// The URL of the homeserver to log into, as a string.
#[property(get = Self::homeserver_url_string)]
homeserver_url_string: PhantomData<Option<String>>,
/// The Matrix client used to log in.
client: RefCell<Option<Client>>,
/// The session that was just logged in.
@ -118,8 +109,8 @@ mod imp {
"login.sso",
Some(&Option::<String>::static_variant_type()),
|obj, _, variant| async move {
let sso_idp_id = variant.and_then(|v| v.get::<Option<String>>()).flatten();
obj.imp().show_in_browser_page(sso_idp_id, false);
let idp = variant.and_then(|v| v.get::<Option<String>>()).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<Vec<Signal>> = 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<Client>) {
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<OwnedServerName>) {
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<String> {
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<String> {
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<Url>) {
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<LoginType>) {
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<String>) {
let Some(client) = self.client.borrow().clone() else {
return;
};
/// The login types supported by the homeserver.
pub(super) fn login_types(&self) -> Vec<LoginType> {
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<String>, 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<LoginType>,
) {
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<OwnedServerName>) {
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<LoginType>) {
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<LoginType> {
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<F: Fn(&Self) + 'static>(
&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!(
"\
<!doctype html>
<html>
<head>
<meta charset=\"utf-8\">
<title>{APP_NAME} - {title}</title>
<style>{css}</style>
</head>
<body>
<div class=\"content\">
{icon}
<h1>{title}</h1>
<p>{message}</p>
</div>
</body>
</html>
"
);
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<String> {
// 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#"<?xml version="1.0" encoding="UTF-8"?>"#)
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#"<div aria-hidden="true">{stripped_icon}</div>"#))
}

20
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.

26
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<ClientId>,
/// The passphrase used to encrypt the local databases.
pub passphrase: Zeroizing<String>,
}
@ -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<Self, ClientSetupError> {
let SessionMeta { user_id, device_id } = meta;
pub(crate) async fn new(client: &Client) -> Result<Self, ClientSetupError> {
// 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(),
};

21
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<Self, ClientSetupError> {
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<Self, ClientSetupError> {
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(())
}

2
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;
};

4
src/session/view/account_settings/general_page/log_out_subpage.ui

@ -85,7 +85,7 @@
<class name="destructive-action"/>
</style>
<property name="title" translatable="yes">Continue</property>
<signal name="activated" handler="logout" swapped="yes"/>
<signal name="activated" handler="log_out" swapped="yes"/>
</object>
</child>
</object>
@ -140,7 +140,7 @@
<class name="destructive-action"/>
</style>
<property name="title" translatable="yes">Try Again</property>
<signal name="activated" handler="logout" swapped="yes"/>
<signal name="activated" handler="log_out" swapped="yes"/>
</object>
</child>
</object>

9
src/session/view/account_settings/general_page/mod.rs

@ -55,6 +55,8 @@ mod imp {
pub user_id: TemplateChild<CopyableRow>,
#[template_child]
pub session_id: TemplateChild<CopyableRow>,
#[template_child]
deactivate_account_button: TemplateChild<adw::ButtonRow>,
/// The current session.
#[property(get, set = Self::set_session, nullable)]
session: glib::WeakRef<Session>,
@ -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.

2
src/session/view/account_settings/general_page/mod.ui

@ -122,7 +122,7 @@
</object>
</child>
<child>
<object class="AdwButtonRow">
<object class="AdwButtonRow" id="deactivate_account_button">
<style>
<class name="destructive-action"/>
</style>

14
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);

11
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

20
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 {

Loading…
Cancel
Save