diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml index 088f12e9..874d4dbd 100644 --- a/data/resources/resources.gresource.xml +++ b/data/resources/resources.gresource.xml @@ -34,6 +34,7 @@ ui/in-app-notification.ui ui/components-avatar.ui ui/avatar-with-selection.ui + ui/components-auth-dialog.ui style.css icons/scalable/actions/send-symbolic.svg icons/scalable/status/welcome.svg diff --git a/data/resources/ui/components-auth-dialog.ui b/data/resources/ui/components-auth-dialog.ui new file mode 100644 index 00000000..66cc4226 --- /dev/null +++ b/data/resources/ui/components-auth-dialog.ui @@ -0,0 +1,132 @@ + + + + + diff --git a/po/POTFILES.in b/po/POTFILES.in index 2086295f..4dc61b5d 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -6,6 +6,7 @@ data/org.gnome.FractalNext.metainfo.xml.in.in # UI files data/resources/ui/add_account.ui +data/resources/ui/components-auth-dialog.ui data/resources/ui/components-avatar.ui data/resources/ui/avatar-with-selection.ui data/resources/ui/content-divider-row.ui @@ -36,6 +37,7 @@ data/resources/ui/window.ui # Rust files src/application.rs +src/components/auth_dialog.rs src/components/avatar.rs src/components/context_menu_bin.rs src/components/custom_entry.rs diff --git a/src/components/auth_dialog.rs b/src/components/auth_dialog.rs new file mode 100644 index 00000000..42067862 --- /dev/null +++ b/src/components/auth_dialog.rs @@ -0,0 +1,383 @@ +use adw::subclass::prelude::*; +use gtk::gdk; +use gtk::gio::prelude::*; +use gtk::glib::clone; +use gtk::prelude::*; +use gtk::subclass::prelude::*; +use gtk::{glib, CompositeTemplate}; +use std::cell::Cell; +use std::future::Future; + +use crate::session::Session; +use crate::session::UserExt; +use crate::RUNTIME; + +use matrix_sdk::{ + ruma::api::{ + client::{ + error::ErrorBody, + r0::uiaa::{ + AuthData as MatrixAuthData, + FallbackAcknowledgement as MatrixFallbackAcknowledgement, + Password as MatrixPassword, UiaaInfo, UiaaResponse, UserIdentifier, + }, + }, + error::{FromHttpResponseError, ServerError}, + OutgoingRequest, + }, + ruma::assign, + HttpError, + HttpError::UiaaError, + HttpResult, +}; + +use std::fmt::Debug; + +pub struct Password { + pub user_id: String, + pub password: String, + pub session: Option, +} + +pub struct FallbackAcknowledgement { + pub session: String, +} + +// FIXME: we can't move the ruma AuthData between threads +// because it's not owned data and doesn't live long enough. +// Therefore we have our own AuthData. +pub enum AuthData { + Password(Password), + FallbackAcknowledgement(FallbackAcknowledgement), +} + +impl AuthData { + pub fn as_matrix_auth_data(&self) -> MatrixAuthData { + match self { + AuthData::Password(Password { + user_id, + password, + session, + }) => MatrixAuthData::Password(assign!(MatrixPassword::new( + UserIdentifier::MatrixId(&user_id), + &password, + ), { session: session.as_deref() })), + AuthData::FallbackAcknowledgement(FallbackAcknowledgement { session }) => { + MatrixAuthData::FallbackAcknowledgement(MatrixFallbackAcknowledgement::new( + &session, + )) + } + } + } +} + +mod imp { + use super::*; + use glib::subclass::{InitializingObject, Signal}; + use glib::SignalHandlerId; + use once_cell::sync::Lazy; + use std::cell::RefCell; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/org/gnome/FractalNext/components-auth-dialog.ui")] + pub struct AuthDialog { + pub session: RefCell>, + #[template_child] + pub stack: TemplateChild, + #[template_child] + pub password: TemplateChild, + #[template_child] + pub error: TemplateChild, + + #[template_child] + pub button_cancel: TemplateChild, + #[template_child] + pub button_ok: TemplateChild, + + #[template_child] + pub open_browser_btn: TemplateChild, + pub open_browser_btn_handler: RefCell>, + } + + #[glib::object_subclass] + impl ObjectSubclass for AuthDialog { + const NAME: &'static str = "ComponentsAuthDialog"; + type Type = super::AuthDialog; + type ParentType = adw::Window; + + fn class_init(klass: &mut Self::Class) { + Self::bind_template(klass); + let response = glib::Variant::from_tuple(&[false.to_variant()]); + klass.add_binding_signal( + gdk::keys::constants::Escape, + gdk::ModifierType::empty(), + "response", + Some(&response), + ); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for AuthDialog { + fn properties() -> &'static [glib::ParamSpec] { + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![glib::ParamSpec::new_object( + "session", + "Session", + "The session", + Session::static_type(), + glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, + )] + }); + + PROPERTIES.as_ref() + } + + fn set_property( + &self, + obj: &Self::Type, + _id: usize, + value: &glib::Value, + pspec: &glib::ParamSpec, + ) { + match pspec.name() { + "session" => obj.set_session(value.get().unwrap()), + _ => unimplemented!(), + } + } + + fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "session" => obj.session().to_value(), + _ => unimplemented!(), + } + } + + fn constructed(&self, obj: &Self::Type) { + self.parent_constructed(obj); + + self.button_cancel + .connect_clicked(clone!(@weak obj => move |_| { + obj.emit_by_name("response", &[&false]).unwrap(); + })); + + self.button_ok + .connect_clicked(clone!(@weak obj => move |_| { + obj.emit_by_name("response", &[&true]).unwrap(); + })); + + obj.connect_close_request( + clone!(@weak obj => @default-return gtk::Inhibit(false), move |_| { + obj.emit_by_name("response", &[&false]).unwrap(); + gtk::Inhibit(false) + }), + ); + } + + fn signals() -> &'static [Signal] { + static SIGNALS: Lazy> = Lazy::new(|| { + vec![Signal::builder( + "response", + &[bool::static_type().into()], + <()>::static_type().into(), + ) + .action() + .build()] + }); + SIGNALS.as_ref() + } + } + impl WidgetImpl for AuthDialog {} + impl WindowImpl for AuthDialog {} + impl AdwWindowImpl for AuthDialog {} +} + +glib::wrapper! { + /// Button showing a spinner, revealing its label once loaded. + pub struct AuthDialog(ObjectSubclass) + @extends gtk::Widget, adw::Window, gtk::Dialog, gtk::Window, @implements gtk::Accessible; +} + +impl AuthDialog { + pub fn new(transient_for: Option<&impl IsA>, session: &Session) -> Self { + glib::Object::new(&[("transient-for", &transient_for), ("session", session)]) + .expect("Failed to create AuthDialog") + } + + pub fn session(&self) -> Option { + let priv_ = imp::AuthDialog::from_instance(self); + priv_.session.borrow().clone() + } + + pub fn set_session(&self, session: Option) { + let priv_ = imp::AuthDialog::from_instance(self); + + if self.session() == session { + return; + }; + + priv_.session.replace(session); + + self.notify("session"); + } + + pub async fn authenticate< + Request: Send + 'static, + F1: Future> + Send + 'static, + FN: Fn(Option) -> F1 + Send + Sync + 'static + Clone, + >( + &self, + callback: FN, + ) -> Option> + where + Request: OutgoingRequest + Debug, + Request::IncomingResponse: Send, + HttpError: From>, + { + let priv_ = imp::AuthDialog::from_instance(self); + let mut auth_data = None; + + loop { + let callback_clone = callback.clone(); + let (sender, receiver) = futures::channel::oneshot::channel(); + RUNTIME.spawn(async move { sender.send(callback_clone(auth_data).await) }); + let response = receiver.await.unwrap(); + + let uiaa_info: UiaaInfo = match response { + Ok(result) => return Some(Ok(result)), + Err(UiaaError(FromHttpResponseError::Http(ServerError::Known( + UiaaResponse::AuthResponse(uiaa_info), + )))) => uiaa_info, + Err(error) => return Some(Err(error)), + }; + + self.show_auth_error(&uiaa_info.auth_error); + + // Find the first flow that matches the completed flow + let flow = uiaa_info + .flows + .iter() + .find(|flow| flow.stages.starts_with(&uiaa_info.completed))?; + + match flow.stages[uiaa_info.completed.len()].as_str() { + "m.login.password" => { + priv_.stack.set_visible_child_name("m.login.password"); + if self.show_and_wait_for_response().await { + let user_id = self + .session() + .unwrap() + .user() + .unwrap() + .user_id() + .to_string(); + let password = priv_.password.text().to_string(); + let session = uiaa_info.session; + + auth_data = Some(AuthData::Password(Password { + user_id, + password, + session, + })); + + continue; + } + } + // TODO implement other authentication types + // See: https://gitlab.gnome.org/GNOME/fractal/-/issues/835 + _ => { + if let Some(session) = uiaa_info.session { + priv_.stack.set_visible_child_name("fallback"); + + let client = self.session()?.client().clone(); + let (sender, receiver) = futures::channel::oneshot::channel(); + RUNTIME.spawn(async move { sender.send(client.homeserver().await) }); + let homeserver = receiver.await.unwrap(); + self.setup_fallback_page( + homeserver.as_str(), + &flow.stages.first()?, + &session, + ); + if self.show_and_wait_for_response().await { + auth_data = + Some(AuthData::FallbackAcknowledgement(FallbackAcknowledgement { + session, + })); + + continue; + } + } + } + } + + return None; + } + } + + async fn show_and_wait_for_response(&self) -> bool { + let (sender, receiver) = futures::channel::oneshot::channel(); + let sender = Cell::new(Some(sender)); + + let handler_id = self.connect_response(move |_, response| { + if let Some(sender) = sender.take() { + sender.send(response).unwrap(); + } + }); + + self.show(); + + let result = receiver.await.unwrap(); + self.disconnect(handler_id); + self.close(); + + result + } + + fn show_auth_error(&self, auth_error: &Option) { + let priv_ = imp::AuthDialog::from_instance(self); + + if let Some(auth_error) = auth_error { + priv_.error.set_label(&auth_error.message); + priv_.error.show(); + } else { + priv_.error.hide(); + } + } + + fn setup_fallback_page(&self, homeserver: &str, auth_type: &str, session: &str) { + let priv_ = imp::AuthDialog::from_instance(self); + + if let Some(handler) = priv_.open_browser_btn_handler.take() { + priv_.open_browser_btn.disconnect(handler); + } + + let uri = format!( + "{}_matrix/client/r0/auth/{}/fallback/web?session={}", + homeserver, auth_type, session + ); + + let handler = + priv_ + .open_browser_btn + .connect_clicked(clone!(@weak self as obj => move |_| { + gtk::show_uri(obj.transient_for().as_ref(), &uri, gdk::CURRENT_TIME); + })); + + priv_.open_browser_btn_handler.replace(Some(handler)); + } + + pub fn connect_response(&self, f: F) -> glib::SignalHandlerId { + self.connect_local("response", true, move |values| { + //FIXME The manuel cast is needed because of https://github.com/gtk-rs/gtk4-rs/issues/591 + let obj: Self = values[0].get::().unwrap().downcast().unwrap(); + let response = values[1].get::().unwrap(); + + f(&obj, response); + + None + }) + .unwrap() + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs index d80ef5a3..8dea5bdd 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,3 +1,4 @@ +mod auth_dialog; mod avatar; mod context_menu_bin; mod custom_entry; @@ -7,6 +8,7 @@ mod pill; mod room_title; mod spinner_button; +pub use self::auth_dialog::{AuthData, AuthDialog}; pub use self::avatar::Avatar; pub use self::context_menu_bin::{ContextMenuBin, ContextMenuBinExt, ContextMenuBinImpl}; pub use self::custom_entry::CustomEntry; diff --git a/src/meson.build b/src/meson.build index 94e8dd32..bf83a98d 100644 --- a/src/meson.build +++ b/src/meson.build @@ -21,6 +21,7 @@ run_command( sources = files( 'application.rs', 'components/avatar.rs', + 'components/auth_dialog.rs', 'components/context_menu_bin.rs', 'components/custom_entry.rs', 'components/label_with_widgets.rs',