diff --git a/simplelogin-gui/Cargo.toml b/simplelogin-gui/Cargo.toml index 2246fd9..c7147e1 100644 --- a/simplelogin-gui/Cargo.toml +++ b/simplelogin-gui/Cargo.toml @@ -7,5 +7,6 @@ edition = "2021" [dependencies] gtk4 = { version = "0.7.3", features = ["blueprint", "v4_12"] } +once_cell = "1.18.0" simplelogin = { version = "0.1.0", path = "../simplelogin" } tokio = { version = "1.33.0", features = ["full"] } diff --git a/simplelogin-gui/build.rs b/simplelogin-gui/build.rs index 5f3d2d6..67cc102 100644 --- a/simplelogin-gui/build.rs +++ b/simplelogin-gui/build.rs @@ -1,6 +1,6 @@ use std::{env, io, fs, path::Path, process::Command}; -const SCHEMA_NAME: &'static str = "im.zander.SimpleLogin.gschema.xml"; +const SCHEMA_NAME: &str = "im.zander.SimpleLogin.gschema.xml"; fn main() -> io::Result<()> { println!("cargo:rerun-if-changed=data/{}", SCHEMA_NAME); diff --git a/simplelogin-gui/data/login_window.blp b/simplelogin-gui/data/login_window.blp index c2f8327..38b38d8 100644 --- a/simplelogin-gui/data/login_window.blp +++ b/simplelogin-gui/data/login_window.blp @@ -1,28 +1,16 @@ using Gtk 4.0; -Popover device_entry_popover { - child: Label { - accessible-role: tooltip; - label: "This is the human readable name that will show up in the API key section of the SimpleLogin website."; - max-width-chars: 30; - wrap: true; - } - - ; -} - template $LoginWindow : ApplicationWindow { title: "SimpleLogin"; - Box { - orientation: vertical; + Overlay forms_overlay { + valign: center; + vexpand: true; - Overlay forms_overlay { - child: forms_wrapper; - valign: end; - vexpand: true; + child: Box forms_wrapper { + orientation: vertical; - Box forms_wrapper { + Box { valign: end; vexpand: true; @@ -55,6 +43,7 @@ template $LoginWindow : ApplicationWindow { Label error_label { label: "ERROR"; margin-top: 5; + visible: false; styles [ "error-message-label", @@ -73,11 +62,23 @@ template $LoginWindow : ApplicationWindow { secondary-icon-name: "dialog-question-symbolic"; truncate-multiline: true; width-chars: 18; + icon-release => $device_help_clicked() swapped; + changed => $login_form_changed() swapped; + activate => $login_form_submit() swapped; layout { column: "1"; row: "3"; } + + Popover device_entry_popover { + child: Label { + accessible-role: tooltip; + label: "This is the human readable name that will show up in the API key section of the SimpleLogin website."; + max-width-chars: 30; + wrap: true; + }; + } } Label { @@ -116,6 +117,8 @@ template $LoginWindow : ApplicationWindow { margin-start: 6; receives-default: true; truncate-multiline: true; + changed => $login_form_changed() swapped; + activate => $login_form_submit() swapped; layout { column: "1"; @@ -127,6 +130,8 @@ template $LoginWindow : ApplicationWindow { hexpand: true; margin-start: 6; show-peek-icon: true; + changed => $login_form_changed() swapped; + activate => $login_form_submit() swapped; layout { column: "1"; @@ -140,6 +145,7 @@ template $LoginWindow : ApplicationWindow { margin-top: 5; sensitive: false; valign: center; + clicked => $login_form_submit() swapped; layout { column: "0"; @@ -185,6 +191,8 @@ template $LoginWindow : ApplicationWindow { margin-start: 8; show-peek-icon: true; width-chars: 27; + changed => $api_key_form_changed() swapped; + activate => $api_key_form_submit() swapped; } Button api_key_button { @@ -192,46 +200,50 @@ template $LoginWindow : ApplicationWindow { label: "Accept"; margin-top: 5; sensitive: false; + clicked => $api_key_form_submit() swapped; } } } - } - Label { - label: "Don\'t have an account? You can register here."; + + Label { + label: "Don\'t have an account? You can register here."; margin-bottom: 10; - use-markup: true; - valign: start; - vexpand: true; - yalign: 0.0; + use-markup: true; + valign: start; + vexpand: true; + yalign: 0.0; + } + }; + + [overlay] + Box loading_overlay { + orientation: vertical; + visible: false; + + Spinner { + spinning: true; + valign: end; + vexpand: true; + + styles [ + "loading-label", + ] + } + + Label { + label: "Loading..."; + valign: start; + vexpand: true; + + styles [ + "loading-label", + ] + } + + styles [ + "loading-label", + ] } } } - -Box loading_overlay { - orientation: vertical; - - Spinner { - spinning: true; - valign: end; - vexpand: true; - - styles [ - "loading-label", - ] - } - - Label { - label: "Loading..."; - valign: start; - vexpand: true; - - styles [ - "loading-label", - ] - } - - styles [ - "loading-label", - ] -} diff --git a/simplelogin-gui/data/totp_window.blp b/simplelogin-gui/data/totp_window.blp index a2ca672..787e0bd 100644 --- a/simplelogin-gui/data/totp_window.blp +++ b/simplelogin-gui/data/totp_window.blp @@ -5,13 +5,12 @@ template $TotpWindow : Window { title: "TOTP Entry"; Overlay overlay { - child: form_wrapper; halign: center; hexpand: true; valign: center; vexpand: true; - Grid form_wrapper { + child: Grid form_wrapper { column-spacing: 5; halign: center; hexpand: true; @@ -49,6 +48,7 @@ template $TotpWindow : Window { Button accept_button { label: "Accept"; + clicked => $accepted() swapped; layout { column: "1"; @@ -58,6 +58,7 @@ template $TotpWindow : Window { Button cancel_button { label: "Cancel"; + clicked => $canceled() swapped; layout { column: "0"; @@ -67,6 +68,7 @@ template $TotpWindow : Window { Entry code_entry { placeholder-text: "TOTP Code"; + changed => $totp_changed() swapped; layout { column: "0"; @@ -77,6 +79,7 @@ template $TotpWindow : Window { Label error_label { label: "ERROR"; + visible: false; styles [ "error-message-label", @@ -88,34 +91,37 @@ template $TotpWindow : Window { row: "2"; } } + }; + + [overlay] + Box loading_overlay { + orientation: vertical; + visible: false; + + Spinner { + spinning: true; + valign: end; + vexpand: true; + + styles [ + "loading-label", + ] + } + + Label { + label: "Loading..."; + valign: start; + vexpand: true; + + styles [ + "loading-label", + ] + } + + styles [ + "loading-label", + ] } } } -Box loading_overlay { - orientation: vertical; - - Spinner { - spinning: true; - valign: end; - vexpand: true; - - styles [ - "loading-label", - ] - } - - Label { - label: "Loading..."; - valign: start; - vexpand: true; - - styles [ - "loading-label", - ] - } - - styles [ - "loading-label", - ] -} diff --git a/simplelogin-gui/src/application.rs b/simplelogin-gui/src/application.rs index 769c5d8..68c1513 100644 --- a/simplelogin-gui/src/application.rs +++ b/simplelogin-gui/src/application.rs @@ -2,10 +2,12 @@ use crate::login_window::LoginWindow; use gtk4 as gtk; use gtk::{glib, gdk, gio, prelude::*, subclass::prelude::*}; +use std::cell::RefCell; +use simplelogin as sl; mod imp { use super::*; - use std::cell::RefCell; + use glib::closure_local; #[derive(Debug, glib::Properties)] #[properties(wrapper_type = super::Application)] @@ -13,6 +15,7 @@ mod imp { #[property(name = "api-key", type = Option, get = Self::api_key, set = Self::set_api_key, nullable)] pub settings: RefCell, + pub context: RefCell, } impl Application { @@ -21,14 +24,39 @@ mod imp { } fn set_api_key(&self, value: Option) { - _ = self.settings.borrow().set("api-key", value).unwrap(); + _ = self.settings.borrow().set("api-key", value); + } + + fn activate_no_api_key(&self) { + let login_window = LoginWindow::new(&self.obj()); + login_window.connect_closure( + "logged-in", false, + closure_local!(@weak-allow-none self as opt_this + => move |win: LoginWindow| { + win.close(); + if let Some(this) = opt_this { + let obj = this.obj(); + let ctx = obj.context().borrow(); + let api_key = ctx.api_key(); + this.set_api_key(api_key.clone()); + this.activate_has_api_key(); + } + })); + login_window.present(); + } + + fn activate_has_api_key(&self) { + println!("Logged in with API Key: {}", self.api_key().unwrap()); } } impl Default for Application { fn default() -> Self { + let settings = RefCell::new(gio::Settings::new(crate::APP_ID)); + let api_key: Option = settings.borrow().value("api-key").get(); Self { - settings: RefCell::new(gio::Settings::new(crate::APP_ID)), + settings, + context: RefCell::new(sl::Context::new(api_key.as_deref())), } } } @@ -42,17 +70,15 @@ mod imp { impl ApplicationImpl for Application { fn activate(&self) { - let css = gtk::CssProvider::new(); - gtk::style_context_add_provider_for_display( - &gdk::Display::default().unwrap(), - &css, gtk::STYLE_PROVIDER_PRIORITY_APPLICATION - ); - css.load_from_string(include_str!("../data/global.css")); - if self.api_key().is_none() { - let login_window = LoginWindow::new(&self.obj()); - login_window.present(); - } else { - todo!(); + let css = gtk::CssProvider::new(); + gtk::style_context_add_provider_for_display( + &gdk::Display::default().unwrap(), + &css, gtk::STYLE_PROVIDER_PRIORITY_APPLICATION + ); + css.load_from_string(include_str!("../data/global.css")); + match self.api_key() { + Some(_) => self.activate_has_api_key(), + None => self.activate_no_api_key(), } } } @@ -72,4 +98,8 @@ impl Application { pub fn new(app_id: &str) -> Self { glib::Object::builder().property("application-id", app_id).build() } + + pub fn context(&self) -> &RefCell { + &self.imp().context + } } diff --git a/simplelogin-gui/src/login_window.rs b/simplelogin-gui/src/login_window.rs index 05f76a8..a52303f 100644 --- a/simplelogin-gui/src/login_window.rs +++ b/simplelogin-gui/src/login_window.rs @@ -1,8 +1,11 @@ use gtk4 as gtk; -use gtk::{glib, gio, subclass::prelude::*}; +use gtk::{glib, gio, prelude::*, subclass::prelude::*}; mod imp { + use crate::{application, totp_window}; use super::*; + use glib::{subclass::Signal, clone, closure_local}; + use once_cell::sync::Lazy; #[derive(Debug, Default, gtk::CompositeTemplate)] #[template(file = "data/login_window.blp")] @@ -31,6 +34,106 @@ mod imp { pub loading_overlay: TemplateChild, } + #[gtk::template_callbacks] + impl LoginWindow { + #[template_callback] + fn login_form_changed(&self, _: >k::Widget) { + let email = &self.email_entry.get().text(); + let password = &self.password_entry.get().text(); + let device = &self.device_entry.get().text(); + self.login_button.get().set_sensitive( + !email.is_empty() && !password.is_empty() && !device.is_empty()) + } + + fn set_error(&self, error: &str) { + self.set_is_loading(false); + let error_label = &self.error_label.get(); + error_label.set_text(error); + error_label.set_visible(true); + } + + fn set_is_loading(&self, loading: bool) { + if loading { + self.error_label.get().set_visible(false); + } + self.loading_overlay.get().set_visible(loading); + self.forms_wrapper.get().set_sensitive(!loading); + } + + fn handle_need_totp(&self, app: &application::Application) { + let totp_window = totp_window::TotpWindow::new(app); + totp_window.connect_closure( + "totp-success", false, + closure_local!(@weak-allow-none self as opt_this => + move |_: totp_window::TotpWindow| { + if let Some(this) = opt_this { + this.obj().emit_by_name::<()>("logged-in", &[]); + } + })); + totp_window.connect_closure( + "totp-failed", false, + closure_local!(@weak-allow-none self as opt_this + => move |_: totp_window::TotpWindow, err: &str| { + if let Some(this) = opt_this { + this.set_error(err); + } + })); + totp_window.present(); + } + + #[template_callback] + fn login_form_submit(&self, _: >k::Widget) { + let main_loop = glib::MainContext::default(); + main_loop.spawn_local(clone!(@weak self as this => async move { + let email = &this.email_entry.get().text(); + let password = &this.password_entry.get().text(); + let device = &this.device_entry.get().text(); + if !email.is_empty() && !password.is_empty() && !device.is_empty() { + this.set_is_loading(true); + let app: application::Application = + this.obj().application().unwrap().downcast().unwrap(); + let mut ctx = app.context().borrow_mut(); + let login_res = ctx.login(email, password, device).await; + drop(ctx); + match login_res { + Ok(need_mfa) if need_mfa => this.handle_need_totp(&app), + Ok(_) => this.obj().emit_by_name::<()>("logged-in", &[]), + Err(e) => this.set_error(e.to_string().as_str()), + } + } + })); + } + + fn get_api_key_from_field(&self) -> String { + self.api_key_entry.get().text().replace('\n', "") + } + + #[template_callback] + fn api_key_form_changed(&self, _: >k::Widget) { + let api_key = self.get_api_key_from_field(); + self.api_key_button.get().set_sensitive(!api_key.is_empty()); + } + + #[template_callback] + fn api_key_form_submit(&self, _: >k::Widget) { + let api_key = self.get_api_key_from_field(); + if !api_key.is_empty() { + let obj = self.obj(); + let app: application::Application = + obj.application().unwrap().downcast().unwrap(); + let mut ctx = app.context().borrow_mut(); + ctx.set_api_key(Some(api_key.as_str())); + drop(ctx); + obj.emit_by_name::<()>("logged-in", &[]); + } + } + + #[template_callback] + fn device_help_clicked(&self, _: gtk::EntryIconPosition) { + self.device_entry_popover.get().popup(); + } + } + #[glib::object_subclass] impl ObjectSubclass for LoginWindow { const NAME: &'static str = "LoginWindow"; @@ -39,6 +142,7 @@ mod imp { fn class_init(class: &mut Self::Class) { class.bind_template(); + class.bind_template_callbacks(); } fn instance_init(obj: &glib::subclass::InitializingObject) { @@ -46,7 +150,18 @@ mod imp { } } - impl ObjectImpl for LoginWindow {} + impl ObjectImpl for LoginWindow { + fn signals() -> &'static [Signal] { + static SIGNALS: Lazy> = Lazy::new(|| + vec![Signal::builder("logged-in").build()]); + SIGNALS.as_ref() + } + + fn constructed(&self) { + self.parent_constructed(); + } + } + impl WidgetImpl for LoginWindow {} impl WindowImpl for LoginWindow {} impl ApplicationWindowImpl for LoginWindow {} diff --git a/simplelogin-gui/src/main.rs b/simplelogin-gui/src/main.rs index 4b5ad30..6682100 100644 --- a/simplelogin-gui/src/main.rs +++ b/simplelogin-gui/src/main.rs @@ -8,7 +8,8 @@ use gtk::glib::{self, g_info}; const APP_ID: &str = "im.zander.SimpleLogin"; -fn main() -> glib::ExitCode { +#[tokio::main] +async fn main() -> glib::ExitCode { #[cfg(debug_assertions)] { std::env::set_var("G_MESSAGES_DEBUG", "SimpleLogin"); // allow us to use a test schema for debug runs diff --git a/simplelogin-gui/src/totp_window.rs b/simplelogin-gui/src/totp_window.rs index 17a6866..6521e67 100644 --- a/simplelogin-gui/src/totp_window.rs +++ b/simplelogin-gui/src/totp_window.rs @@ -2,12 +2,84 @@ use gtk4 as gtk; use gtk::{glib, gio, prelude::*, subclass::prelude::*}; mod imp { + use crate::application; use super::*; + use once_cell::sync::Lazy; + use glib::{clone, subclass::Signal}; #[derive(Debug, Default, gtk::CompositeTemplate)] #[template(file = "data/totp_window.blp")] pub struct TotpWindow { + #[template_child] + pub overlay: gtk::TemplateChild, + #[template_child] + pub form_wrapper: gtk::TemplateChild, + #[template_child] + pub accept_button: gtk::TemplateChild, + #[template_child] + pub cancel_button: gtk::TemplateChild, + #[template_child] + pub code_entry: gtk::TemplateChild, + #[template_child] + pub error_label: gtk::TemplateChild, + #[template_child] + pub loading_overlay: gtk::TemplateChild, + } + #[gtk::template_callbacks] + impl TotpWindow { + #[template_callback] + fn totp_changed(&self, _: >k::Entry) { + let totp = &self.code_entry.get().text(); + self.accept_button.get().set_sensitive(!totp.is_empty()); + } + + #[template_callback] + fn canceled(&self, _: >k::Button) { + self.obj().close(); + } + + #[template_callback] + fn accepted(&self, _: >k::Button) { + let main_loop = glib::MainContext::default(); + main_loop.spawn_local(clone!(@weak self as this => async move { + let obj = this.obj(); + this.set_is_loading(true); + this.loading_overlay.get().set_visible(true); + let app: application::Application = + obj.application().unwrap().downcast().unwrap(); + let mut ctx = app.context().borrow_mut(); + let totp = &this.code_entry.get().text(); + let mfa_res = ctx.mfa(totp).await; + drop(ctx); + match mfa_res { + Ok(()) => { + obj.emit_by_name::<()>("totp-success", &[]); + obj.close(); + }, + Err(e) if e.is_json() => this.set_error(e.to_string().as_str()), + Err(e) => { + obj.emit_by_name::<()>("totp-failed", &[&e.to_string()]); + obj.close(); + }, + } + })); + } + + fn set_error(&self, error: &str) { + self.set_is_loading(false); + let error_label = &self.error_label.get(); + error_label.set_text(error); + error_label.set_visible(true); + } + + fn set_is_loading(&self, loading: bool) { + if loading { + self.error_label.get().set_visible(false); + } + self.loading_overlay.get().set_visible(loading); + self.form_wrapper.get().set_sensitive(!loading); + } } #[glib::object_subclass] @@ -18,6 +90,7 @@ mod imp { fn class_init(class: &mut Self::Class) { class.bind_template(); + class.bind_template_callbacks(); } fn instance_init(obj: &glib::subclass::InitializingObject) { @@ -25,7 +98,22 @@ mod imp { } } - impl ObjectImpl for TotpWindow {} + impl ObjectImpl for TotpWindow { + fn signals() -> &'static [Signal] { + static SIGNALS: Lazy> = Lazy::new(|| + vec![Signal::builder("totp-success").build(), + Signal::builder("totp-failed") + .param_types([str::static_type()]) + .build()]); + SIGNALS.as_ref() + } + + fn constructed(&self) { + self.parent_constructed(); + self.overlay.add_overlay(&self.loading_overlay.get()); + } + } + impl WidgetImpl for TotpWindow {} impl WindowImpl for TotpWindow {} } diff --git a/simplelogin/src/lib.rs b/simplelogin/src/lib.rs index c476e76..a71e657 100644 --- a/simplelogin/src/lib.rs +++ b/simplelogin/src/lib.rs @@ -1,8 +1,9 @@ #![allow(dead_code)] -use core::fmt; +use std::fmt::{self, Display}; use serde::{Serialize, Deserialize}; - + +#[derive(Debug)] pub struct Context { client: reqwest::Client, api_key: Option, @@ -13,6 +14,16 @@ pub struct Context { device: Option, } +impl Display for Context { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + let api_key = match &self.api_key { + Some(key) => format!("\"{}\"", key), + None => "None".to_owned(), + }; + writeln!(f, "Context(api_key: \"{}\", url: \"{}\")", api_key, self.url) + } +} + #[derive(Deserialize)] struct ErrorResponse { error: String, @@ -113,15 +124,15 @@ impl Context { } } - pub fn api_key(&self) -> Option<&String> { - self.api_key.as_ref() + pub fn api_key(&self) -> &Option { + &self.api_key } pub fn set_api_key(&mut self, api_key: Option<&str>) { self.api_key = api_key.map(|s| s.to_owned()); } - fn parse_error_json(error_body: &String) -> String { + fn parse_error_json(error_body: &str) -> String { match serde_json::from_str(error_body) { Ok::(obj) => obj.error, Err(_) => "Could not parse error response JSON".to_owned(), @@ -136,7 +147,7 @@ impl Context { let status = resp.status(); let resp_text = resp.text().await .map_err(|e| Error::new(ErrorKind::Http, e.to_string()))?; - return if status.is_success() { + if status.is_success() { Ok(resp_text) } else if status.is_client_error() { Err(Error::new(ErrorKind::Auth, Self::parse_error_json(&resp_text))) @@ -190,15 +201,13 @@ impl Context { } else { Err(Error::new(ErrorKind::Json, "Error response did not contain mfa key")) } + } else if resp_obj.api_key.is_some() { + self.api_key = resp_obj.api_key; + self.mfa_key = None; + self.device = None; + Ok(true) } else { - if resp_obj.api_key.is_some() { - self.api_key = resp_obj.api_key; - self.mfa_key = None; - self.device = None; - Ok(true) - } else { - Err(Error::new(ErrorKind::Json, "Response did not contain api key")) - } + Err(Error::new(ErrorKind::Json, "Response did not contain api key")) } }