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"))
}
}