diff --git a/data/resources/assets/homeserver.svg b/data/resources/assets/homeserver.svg new file mode 100644 index 00000000..4d0940cf --- /dev/null +++ b/data/resources/assets/homeserver.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Gnome Symbolic Icons + + diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml index fb7d7b4e..c8d2868d 100644 --- a/data/resources/resources.gresource.xml +++ b/data/resources/resources.gresource.xml @@ -1,6 +1,7 @@ + assets/homeserver.svg assets/other-device.svg assets/setup-complete.svg assets/welcome.svg @@ -52,6 +53,7 @@ ui/greeter.ui ui/identity-verification-widget.ui ui/in-app-notification.ui + ui/login-advanced-dialog.ui ui/login.ui ui/media-viewer.ui ui/member-menu.ui diff --git a/data/resources/style.css b/data/resources/style.css index 331a8eab..38dc3fa1 100644 --- a/data/resources/style.css +++ b/data/resources/style.css @@ -81,10 +81,14 @@ headerbar .suggested-action { /* Login */ -.login { +login { min-width: 250px; } +login entry { + padding: 18px 24px; +} + /* Session */ .session-loading-spinner { diff --git a/data/resources/ui/login-advanced-dialog.ui b/data/resources/ui/login-advanced-dialog.ui new file mode 100644 index 00000000..762d4407 --- /dev/null +++ b/data/resources/ui/login-advanced-dialog.ui @@ -0,0 +1,32 @@ + + + + diff --git a/data/resources/ui/login.ui b/data/resources/ui/login.ui index 0743eeee..790c4bf4 100644 --- a/data/resources/ui/login.ui +++ b/data/resources/ui/login.ui @@ -12,9 +12,9 @@ - - app.show-greeter + go-previous-symbolic + login.prev @@ -34,82 +34,156 @@ True - credentials + homeserver - 400 - 300 - center - + 360 + 360 + 0 + 24 + 24 + 24 + vertical - 18 + center + 24 + + + + + resource:///org/gnome/FractalNext/assets/homeserver.svg + + + + - + + vertical + 6 - - False - False - False - - - True - GTK_INPUT_PURPOSE_URL - Homeserver - 6 - 6 - 6 - 6 - - + + + true + document-edit-symbolic + false + false - - False - False - False - - - True - Matrix Username - 6 - 6 - 6 - 6 - - + + + left + 0.0 + 6 + 6 + true + true + + + + + + + + center + Advanced… + login.open-advanced + + + + + + + + + + + password + + + 360 + 360 + center + + + vertical + 30 + + + vertical + 6 + center + + + - - False - False - False - - - True - True - Password - 6 - 6 - 6 - 6 + + 6 + center + + Homeserver URL + + + user-home-symbolic - + + + + + + + + + + + + true + Matrix Username + document-edit-symbolic + false + false - - True - _Forgot Password? - https://app.element.io/#/forgot_password + + vertical + 12 + + + + True + True + Password + + + + + True + _Forgot Password? + https://app.element.io/#/forgot_password + + diff --git a/po/POTFILES.in b/po/POTFILES.in index 2275d5bd..39576bb6 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -25,6 +25,7 @@ data/resources/ui/event-menu.ui data/resources/ui/event-source-dialog.ui data/resources/ui/greeter.ui data/resources/ui/identity-verification-widget.ui +data/resources/ui/login-advanced-dialog.ui data/resources/ui/login.ui data/resources/ui/member-menu.ui data/resources/ui/room-creation.ui @@ -35,6 +36,7 @@ data/resources/ui/sidebar.ui # Rust files src/application.rs +src/login.rs src/secret.rs src/session/account_settings/devices_page/device_list.rs src/session/account_settings/devices_page/device_row.rs diff --git a/src/login.rs b/src/login.rs index 8129f594..c477ea4c 100644 --- a/src/login.rs +++ b/src/login.rs @@ -1,12 +1,25 @@ -use adw::subclass::prelude::BinImpl; -use gtk::{self, glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate}; -use log::debug; +use adw::{prelude::*, subclass::prelude::BinImpl}; +use gettextrs::gettext; +use gtk::{self, glib, glib::clone, subclass::prelude::*, CompositeTemplate}; +use log::{debug, warn}; +use matrix_sdk::{ + config::RequestConfig, + ruma::{ + api::client::unversioned::get_supported_versions, identifiers::Error as IdentifierError, + ServerName, UserId, + }, + Client, Result as MatrixResult, +}; +use tokio::task::JoinHandle; use url::{ParseError, Url}; -use crate::{components::SpinnerButton, Session}; +use crate::{ + components::SpinnerButton, error::Error, login_advanced_dialog::LoginAdvancedDialog, spawn, + spawn_tokio, user_facing_error::UserFacingError, Session, +}; mod imp { - use std::cell::RefCell; + use std::cell::{Cell, RefCell}; use glib::{ subclass::{InitializingObject, Signal}, @@ -21,18 +34,28 @@ mod imp { pub struct Login { pub current_session: RefCell>, #[template_child] + pub back_button: TemplateChild, + #[template_child] pub next_button: TemplateChild, #[template_child] pub main_stack: TemplateChild, #[template_child] pub homeserver_entry: TemplateChild, #[template_child] + pub homeserver_help: TemplateChild, + #[template_child] + pub password_title: TemplateChild, + #[template_child] pub username_entry: TemplateChild, #[template_child] pub password_entry: TemplateChild, pub prepared_source_id: RefCell>, pub logged_out_source_id: RefCell>, pub ready_source_id: RefCell>, + /// Whether auto-discovery is enabled. + pub autodiscovery: Cell, + /// The homeserver to log into. + pub homeserver: RefCell>, } #[glib::object_subclass] @@ -43,8 +66,15 @@ mod imp { fn class_init(klass: &mut Self::Class) { Self::bind_template(klass); + klass.set_css_name("login"); klass.set_accessible_role(gtk::AccessibleRole::Group); klass.install_action("login.next", None, move |widget, _, _| widget.forward()); + klass.install_action("login.prev", None, move |widget, _, _| widget.backward()); + klass.install_action("login.open-advanced", None, move |widget, _, _| { + spawn!(clone!(@weak widget => async move { + widget.open_advanced_dialog().await; + })); + }); } fn instance_init(obj: &InitializingObject) { @@ -65,17 +95,67 @@ mod imp { SIGNALS.as_ref() } + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + glib::ParamSpecString::new( + "homeserver", + "Homeserver", + "The homeserver to log into", + None, + glib::ParamFlags::READABLE, + ), + glib::ParamSpecBoolean::new( + "autodiscovery", + "Auto-discovery", + "Whether auto-discovery is enabled", + true, + glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT, + ), + ] + }); + + PROPERTIES.as_ref() + } + + fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "homeserver" => obj.homeserver_pretty().to_value(), + "autodiscovery" => obj.autodiscovery().to_value(), + _ => unimplemented!(), + } + } + + fn set_property( + &self, + obj: &Self::Type, + _id: usize, + value: &glib::Value, + pspec: &glib::ParamSpec, + ) { + match pspec.name() { + "autodiscovery" => obj.set_autodiscovery(value.get().unwrap()), + _ => unimplemented!(), + } + } + fn constructed(&self, obj: &Self::Type) { obj.action_set_enabled("login.next", false); self.parent_constructed(obj); + self.main_stack + .connect_visible_child_notify(clone!(@weak obj => move |_| + obj.update_next_action() + )); + obj.update_next_action(); + self.homeserver_entry - .connect_changed(clone!(@weak obj => move |_| obj.enable_next_action())); + .connect_changed(clone!(@weak obj => move |_| obj.update_next_action())); self.username_entry - .connect_changed(clone!(@weak obj => move |_| obj.enable_next_action())); + .connect_changed(clone!(@weak obj => move |_| obj.update_next_action())); self.password_entry - .connect_changed(clone!(@weak obj => move |_| obj.enable_next_action())); + .connect_changed(clone!(@weak obj => move |_| obj.update_next_action())); } } @@ -85,6 +165,7 @@ mod imp { } glib::wrapper! { + /// A widget handling the login flows. pub struct Login(ObjectSubclass) @extends gtk::Widget, adw::Bin, @implements gtk::Accessible; } @@ -94,28 +175,223 @@ impl Login { glib::Object::new(&[]).expect("Failed to create Login") } - fn enable_next_action(&self) { + pub fn homeserver(&self) -> Option { + self.imp().homeserver.borrow().clone() + } + + pub fn homeserver_pretty(&self) -> Option { + let homeserver = self.homeserver(); + homeserver + .as_ref() + .and_then(|url| url.as_ref().strip_suffix('/').map(ToOwned::to_owned)) + .or_else(|| homeserver.as_ref().map(ToString::to_string)) + } + + pub fn set_homeserver(&self, homeserver: Option) { + let priv_ = imp::Login::from_instance(self); + + if self.homeserver() == homeserver { + return; + } + + priv_.homeserver.replace(homeserver); + self.notify("homeserver"); + } + + fn visible_child(&self) -> String { + let priv_ = imp::Login::from_instance(self); + priv_.main_stack.visible_child_name().unwrap().into() + } + + fn set_visible_child(&self, visible_child: &str) { + let priv_ = imp::Login::from_instance(self); + priv_.main_stack.set_visible_child_name(visible_child); + } + + fn update_next_action(&self) { + let priv_ = imp::Login::from_instance(self); + match self.visible_child().as_ref() { + "homeserver" => { + let homeserver = priv_.homeserver_entry.text(); + let enabled = if self.autodiscovery() { + build_server_name(homeserver.as_str()).is_ok() + } else { + build_homeserver_url(homeserver.as_str()).is_ok() + }; + self.action_set_enabled("login.next", enabled); + priv_.next_button.set_visible(true); + } + "password" => { + let username_length = priv_.username_entry.text_length(); + let password_length = priv_.password_entry.text().len(); + self.action_set_enabled("login.next", username_length != 0 && password_length != 0); + priv_.next_button.set_visible(true); + } + _ => { + priv_.next_button.set_visible(false); + } + } + } + + fn forward(&self) { + match self.visible_child().as_ref() { + "homeserver" => { + if self.autodiscovery() { + self.try_autodiscovery(); + } else { + self.check_homeserver(); + } + } + "password" => self.login_with_password(), + _ => {} + } + } + + fn backward(&self) { + match self.visible_child().as_ref() { + "password" => self.set_visible_child("homeserver"), + _ => { + self.activate_action("app.show-greeter", None).unwrap(); + } + } + } + + pub fn autodiscovery(&self) -> bool { + self.imp().autodiscovery.get() + } + + fn set_autodiscovery(&self, autodiscovery: bool) { let priv_ = self.imp(); - let homeserver = priv_.homeserver_entry.text(); - let username_length = priv_.username_entry.text_length(); - let password_length = priv_.password_entry.text().len(); - - self.action_set_enabled( - "login.next", - homeserver.len() != 0 - && build_homeserver_url(homeserver.as_str()).is_ok() - && username_length != 0 - && password_length != 0, + + priv_.autodiscovery.set(autodiscovery); + if autodiscovery { + priv_ + .homeserver_entry + .set_placeholder_text(Some(&gettext("Domain Name…"))); + priv_.homeserver_help.set_markup(&gettext( + "The domain of your Matrix homeserver, for example gnome.org", + )); + } else { + priv_ + .homeserver_entry + .set_placeholder_text(Some(&gettext("Homeserver URL…"))); + priv_.homeserver_help.set_markup(&gettext("The URL of your Matrix homeserver, for example https://gnome.modular.im")); + } + self.update_next_action(); + } + + async fn open_advanced_dialog(&self) { + let dialog = + LoginAdvancedDialog::new(self.root().unwrap().downcast_ref::().unwrap()); + self.bind_property("autodiscovery", &dialog, "autodiscovery") + .flags(glib::BindingFlags::SYNC_CREATE | glib::BindingFlags::BIDIRECTIONAL) + .build(); + dialog.run_future().await; + } + + fn try_autodiscovery(&self) { + let server = build_server_name(self.imp().homeserver_entry.text().as_str()).unwrap(); + let mxid = UserId::parse_with_server_name("user", &server).unwrap(); + + self.freeze(); + + let handle = spawn_tokio!(async move { Client::new_from_user_id(&mxid).await }); + + spawn!( + glib::PRIORITY_DEFAULT_IDLE, + clone!(@weak self as obj => async move { + match handle.await.unwrap() { + Ok(client) => { + let homeserver = client.homeserver().await; + obj.set_homeserver(Some(homeserver)); + obj.show_password_page(); + } + Err(error) => { + warn!("Failed to discover homeserver: {}", error); + let error_string = error.to_user_facing(); + + obj.parent_window().append_error(&Error::new(move |_| { + let error_label = gtk::Label::builder() + .label(&error_string) + .wrap(true) + .build(); + Some(error_label.upcast()) + })); + } + }; + obj.unfreeze(); + }) ); } - fn forward(&self) { - self.login(); + fn check_homeserver(&self) { + let homeserver = build_homeserver_url(self.imp().homeserver_entry.text().as_str()).unwrap(); + let homeserver_clone = homeserver.clone(); + + self.freeze(); + + let handle: JoinHandle> = spawn_tokio!(async move { + let client = Client::new(homeserver_clone)?; + Ok(client + .send( + get_supported_versions::Request::new(), + Some(RequestConfig::new().disable_retry()), + ) + .await?) + }); + + spawn!( + glib::PRIORITY_DEFAULT_IDLE, + clone!(@weak self as obj => async move { + match handle.await.unwrap() { + Ok(_) => { + obj.set_homeserver(Some(homeserver)); + obj.show_password_page(); + } + Err(error) => { + warn!("Failed to check homeserver: {}", error); + let error_string = error.to_user_facing(); + + obj.parent_window().append_error(&Error::new(move |_| { + let error_label = gtk::Label::builder() + .label(&error_string) + .wrap(true) + .build(); + Some(error_label.upcast()) + })); + } + }; + obj.unfreeze(); + }) + ); } - fn login(&self) { + fn show_password_page(&self) { let priv_ = self.imp(); - let homeserver = priv_.homeserver_entry.text().to_string(); + if self.autodiscovery() { + // Translators: the variable is a domain name, eg. gnome.org. + priv_.password_title.set_markup(&gettext!( + "Connecting to {}", + format!( + "{}", + priv_.homeserver_entry.text() + ) + )); + } else { + priv_.password_title.set_markup(&gettext!( + "Connecting to {}", + format!( + "{}", + self.homeserver_pretty().unwrap() + ) + )); + } + self.set_visible_child("password"); + } + + fn login_with_password(&self) { + let priv_ = self.imp(); + let homeserver = self.homeserver().unwrap(); let username = priv_.username_entry.text().to_string(); let password = priv_.password_entry.text().to_string(); @@ -124,11 +400,7 @@ impl Login { let session = Session::new(); self.set_handler_for_prepared_session(&session); - session.login_with_password( - build_homeserver_url(homeserver.as_str()).unwrap(), - username, - password, - ); + session.login_with_password(homeserver, username, password, self.autodiscovery()); priv_.current_session.replace(Some(session)); } @@ -137,6 +409,7 @@ impl Login { priv_.homeserver_entry.set_text(""); priv_.username_entry.set_text(""); priv_.password_entry.set_text(""); + priv_.autodiscovery.set(true); self.unfreeze(); self.drop_session_reference(); } @@ -152,9 +425,9 @@ impl Login { fn unfreeze(&self) { let priv_ = self.imp(); - self.action_set_enabled("login.next", true); priv_.next_button.set_loading(false); priv_.main_stack.set_sensitive(true); + self.update_next_action(); } pub fn connect_new_session( @@ -240,6 +513,14 @@ impl Default for Login { } } +fn build_server_name(server: &str) -> Result, IdentifierError> { + let server = server + .strip_prefix("http://") + .or_else(|| server.strip_prefix("https://")) + .unwrap_or(server); + ServerName::parse(server) +} + fn build_homeserver_url(server: &str) -> Result { if server.starts_with("http://") || server.starts_with("https://") { Url::parse(server) diff --git a/src/login_advanced_dialog.rs b/src/login_advanced_dialog.rs new file mode 100644 index 00000000..8985cb67 --- /dev/null +++ b/src/login_advanced_dialog.rs @@ -0,0 +1,118 @@ +use std::cell::Cell; + +use adw::subclass::prelude::*; +use gtk::{gdk, glib, prelude::*, subclass::prelude::*, CompositeTemplate}; + +mod imp { + use glib::subclass::InitializingObject; + use once_cell::sync::Lazy; + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/org/gnome/FractalNext/login-advanced-dialog.ui")] + pub struct LoginAdvancedDialog { + pub autodiscovery: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for LoginAdvancedDialog { + const NAME: &'static str = "LoginAdvancedDialog"; + type Type = super::LoginAdvancedDialog; + type ParentType = adw::PreferencesWindow; + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + + klass.add_binding_signal( + gdk::Key::Escape, + gdk::ModifierType::empty(), + "close-request", + None, + ); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for LoginAdvancedDialog { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![glib::ParamSpecBoolean::new( + "autodiscovery", + "Auto-discovery", + "Whether auto-discovery is enabled", + true, + glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT, + )] + }); + + PROPERTIES.as_ref() + } + + fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "autodiscovery" => obj.autodiscovery().to_value(), + _ => unimplemented!(), + } + } + + fn set_property( + &self, + obj: &Self::Type, + _id: usize, + value: &glib::Value, + pspec: &glib::ParamSpec, + ) { + match pspec.name() { + "autodiscovery" => obj.set_autodiscovery(value.get().unwrap()), + _ => unimplemented!(), + } + } + } + + impl WidgetImpl for LoginAdvancedDialog {} + impl WindowImpl for LoginAdvancedDialog {} + impl AdwWindowImpl for LoginAdvancedDialog {} + impl PreferencesWindowImpl for LoginAdvancedDialog {} +} + +glib::wrapper! { + pub struct LoginAdvancedDialog(ObjectSubclass) + @extends gtk::Widget, gtk::Window, adw::Window, adw::PreferencesWindow, @implements gtk::Accessible; +} + +impl LoginAdvancedDialog { + pub fn new(window: >k::Window) -> Self { + glib::Object::new(&[("transient-for", window)]) + .expect("Failed to create LoginAdvancedDialog") + } + + pub fn autodiscovery(&self) -> bool { + self.imp().autodiscovery.get() + } + + pub fn set_autodiscovery(&self, autodiscovery: bool) { + let priv_ = self.imp(); + + priv_.autodiscovery.set(autodiscovery); + self.notify("autodiscovery"); + } + + pub async fn run_future(&self) { + let (sender, receiver) = futures::channel::oneshot::channel(); + let sender = Cell::new(Some(sender)); + + self.connect_close_request(move |_| { + if let Some(sender) = sender.take() { + sender.send(()).unwrap(); + } + gtk::Inhibit(false) + }); + + self.show(); + receiver.await.unwrap(); + } +} diff --git a/src/main.rs b/src/main.rs index 4f65274a..97162920 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ mod contrib; mod error; mod greeter; mod login; +mod login_advanced_dialog; mod secret; mod session; mod user_facing_error; diff --git a/src/session/mod.rs b/src/session/mod.rs index 5af81403..eb91c29c 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -284,7 +284,13 @@ impl Session { } } - pub fn login_with_password(&self, homeserver: Url, username: String, password: String) { + pub fn login_with_password( + &self, + homeserver: Url, + username: String, + password: String, + use_discovery: bool, + ) { self.imp().logout_on_dispose.set(true); let mut path = glib::user_data_dir(); path.push( @@ -307,6 +313,12 @@ impl Session { .passphrase(passphrase.clone()) .store_path(path.clone()); + let config = if use_discovery { + config.use_discovery_response() + } else { + config + }; + let client = Client::new_with_config(homeserver.clone(), config).unwrap(); let response = client .login(&username, &password, None, Some("Fractal Next"))