diff --git a/resources/setup.blp b/resources/setup.blp index 5bb1855..c70831d 100644 --- a/resources/setup.blp +++ b/resources/setup.blp @@ -25,7 +25,7 @@ template $AudreyUiSetup: Adw.PreferencesDialog { sensitive: bind template.authn_can_edit; text: bind template.server_url bidirectional; - changed => $on_authn_changed (); + changed => $on_authn_changed () swapped; } Adw.EntryRow { @@ -33,7 +33,7 @@ template $AudreyUiSetup: Adw.PreferencesDialog { sensitive: bind template.authn_can_edit; text: bind template.username bidirectional; - changed => $on_authn_changed (); + changed => $on_authn_changed () swapped; } Adw.PasswordEntryRow { @@ -41,7 +41,7 @@ template $AudreyUiSetup: Adw.PreferencesDialog { sensitive: bind template.authn_can_edit; text: bind template.password bidirectional; - changed => $on_authn_changed (); + changed => $on_authn_changed () swapped; } Adw.ActionRow { @@ -57,7 +57,7 @@ template $AudreyUiSetup: Adw.PreferencesDialog { title: _("Connect and save"); sensitive: bind template.authn_can_validate; - activated => $on_authn_validate_activated (); + activated => $on_authn_validate_activated () swapped; } } } diff --git a/src/main.rs b/src/main.rs index d0c38a8..aba1097 100644 --- a/src/main.rs +++ b/src/main.rs @@ -44,24 +44,5 @@ fn main() -> glib::ExitCode { setlocale(LocaleCategory::LcNumeric, "C.UTF-8"); let app = Application::new(); - // smol test for the subsonic client - glib::spawn_future_local(async { - let keyring = oo7::Keyring::new().await.unwrap(); - let attributes = vec![("xdg:schema", APP_ID)]; - let items = keyring.search_items(&attributes).await.unwrap(); - if !items.is_empty() { - let item = &items[0]; - let attributes = item.attributes().await.unwrap(); - let client = subsonic::Client::with_password( - &attributes["server-url"], - &attributes["username"], - &item.secret().await.unwrap(), - ) - .unwrap(); - client.ping().await.unwrap(); - println!("{:#?}", client.get_random_songs(10).await.unwrap()); - } - }); - app.run() } diff --git a/src/meson.build b/src/meson.build index 5b19d3c..6345583 100644 --- a/src/meson.build +++ b/src/meson.build @@ -3,7 +3,6 @@ audrey_sources = [ 'playbin.vala', 'rust.vapi', 'subsonic.vala', - 'ui/setup.vala', 'ui/window.vala', ] diff --git a/src/rust.h b/src/rust.h index fcaa73c..30ca456 100644 --- a/src/rust.h +++ b/src/rust.h @@ -24,3 +24,8 @@ typedef void AudreyUiPlayQueue; typedef void AudreyMpris; AudreyMpris *audrey_mpris_new(void *window); guint audrey_mpris_register_object(AudreyMpris *self, GDBusConnection *conn, const gchar *object_path, GError **error); + +// ui::Setup +typedef void AudreyUiSetup; +AudreyUiSetup *audrey_ui_setup_new(); +void audrey_ui_setup_load(AudreyUiSetup *self); diff --git a/src/rust.vapi b/src/rust.vapi index 1c226ab..40942f2 100644 --- a/src/rust.vapi +++ b/src/rust.vapi @@ -28,4 +28,19 @@ namespace Audrey { public bool can_clear_all { get; } } + public class Ui.Setup : Adw.PreferencesDialog { + public string status { get; } + public bool authn_can_edit { get; } + public bool authn_can_validate { get; } + public string server_url { get; set; } + public string username { get; set; } + public string password { get; set; } + + public signal void connected (Subsonic.Client api); + + public Setup (); + public void load (); + public void save (); + } + } diff --git a/src/subsonic.rs b/src/subsonic.rs index 1915693..d7b09b1 100644 --- a/src/subsonic.rs +++ b/src/subsonic.rs @@ -18,6 +18,18 @@ pub enum Error { OtherError(&'static str), } +use std::fmt; +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::UrlParseError(err) => fmt::Display::fmt(err, f), + Self::ReqwestError(err) => fmt::Display::fmt(err, f), + Self::SubsonicError(err) => fmt::Display::fmt(&err.message, f), + Self::OtherError(err) => fmt::Display::fmt(err, f), + } + } +} + impl From for Error { fn from(err: reqwest::Error) -> Self { // don't print secret salt/token combo @@ -71,7 +83,7 @@ impl Client { .map_err(Error::UrlParseError)?; if base_url.scheme() != "http" && base_url.scheme() != "https" { - return Err(Error::OtherError("Url scheme is not HTTP(s)")); + return Err(Error::OtherError("url scheme is not HTTP(s)")); } Ok(Client { diff --git a/src/subsonic_vala/client.rs b/src/subsonic_vala/client.rs index 95e1853..4de764d 100644 --- a/src/subsonic_vala/client.rs +++ b/src/subsonic_vala/client.rs @@ -1,5 +1,6 @@ mod ffi { use gtk::glib; + use std::ffi::c_char; #[repr(C)] pub struct AudreySubsonicClient { @@ -13,9 +14,16 @@ mod ffi { extern "C" { pub fn audrey_subsonic_client_get_type() -> glib::ffi::GType; + pub fn audrey_subsonic_client_new_with_token( + server_url: *mut c_char, + username: *mut c_char, + token: *mut c_char, + salt: *mut c_char, + ) -> *mut AudreySubsonicClient; } } +use glib::translate::{from_glib_none, ToGlibPtr}; use gtk::glib; glib::wrapper! { @@ -25,3 +33,16 @@ glib::wrapper! { type_ => || ffi::audrey_subsonic_client_get_type(), } } + +impl Client { + pub fn with_token(server_url: &str, username: &str, token: &str, salt: &str) -> Self { + unsafe { + from_glib_none(ffi::audrey_subsonic_client_new_with_token( + server_url.to_glib_none().0, + username.to_glib_none().0, + token.to_glib_none().0, + salt.to_glib_none().0, + )) + } + } +} diff --git a/src/ui/setup.rs b/src/ui/setup.rs index 3513e1e..1b078f1 100644 --- a/src/ui/setup.rs +++ b/src/ui/setup.rs @@ -1,29 +1,221 @@ -mod ffi { - use gtk::glib; +mod imp { + use adw::{glib, prelude::*, subclass::prelude::*}; + use glib::subclass::{InitializingObject, Signal}; + use std::cell::{Cell, RefCell}; + use std::sync::OnceLock; - #[repr(C)] - pub struct AudreyUiSetup { - parent_instance: adw::ffi::AdwPreferencesDialog, + #[derive(gtk::CompositeTemplate, glib::Properties, Default)] + #[template(resource = "/eu/callcc/audrey/setup.ui")] + #[properties(wrapper_type = super::Setup)] + pub struct Setup { + #[property(get, set)] + status: RefCell, + + #[property(get, set)] + authn_can_edit: Cell, + #[property(get, set)] + authn_can_validate: Cell, + + #[property(get, set)] + server_url: RefCell, + #[property(get, set)] + username: RefCell, + #[property(get, set)] + password: RefCell, } - #[repr(C)] - pub struct AudreyUiSetupClass { - parent_class: adw::ffi::AdwPreferencesDialogClass, + #[glib::object_subclass] + impl ObjectSubclass for Setup { + const NAME: &'static str = "AudreyUiSetup"; + type Type = super::Setup; + type ParentType = adw::PreferencesDialog; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + klass.bind_template_callbacks(); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } } - extern "C" { - pub fn audrey_ui_setup_get_type() -> glib::ffi::GType; + #[glib::derived_properties] + impl ObjectImpl for Setup { + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![Signal::builder("connected") + .param_types([crate::subsonic_vala::Client::static_type()]) + .build()] + }) + } + } + + impl WidgetImpl for Setup {} + + impl AdwDialogImpl for Setup {} + + impl PreferencesDialogImpl for Setup {} + + #[gtk::template_callbacks] + impl Setup { + #[template_callback] + fn on_authn_changed(&self) { + self.obj().set_authn_can_validate(true); + } + + #[template_callback] + pub(super) async fn on_authn_validate_activated(&self) { + self.obj().set_authn_can_validate(false); + self.obj().set_authn_can_edit(false); + self.obj().set_status("Connecting..."); + + let api = match crate::subsonic::Client::with_password( + &self.obj().server_url(), + &self.obj().username(), + self.obj().password().as_bytes(), + ) { + Ok(api) => api, + Err(err) => { + self.obj().set_status(format!("Error: {err}")); + self.obj().set_authn_can_validate(true); + self.obj().set_authn_can_edit(true); + return; + } + }; + + match api.ping().await { + Ok(()) => { + self.obj().set_status("Connected"); + self.save_async().await; + + // please REMOVEME once we've killed vala + fn get_random_salt(length: usize) -> String { + use rand::Rng; + let mut rng = rand::thread_rng(); + std::iter::repeat(()) + // 0.9: s/distributions/distr + .map(|()| rng.sample(rand::distributions::Alphanumeric)) + .map(char::from) + .take(length) + .collect::() + } + let new_salt = get_random_salt(8); + use md5::Digest; + let mut hasher = md5::Md5::new(); + hasher.update(self.obj().password().as_bytes()); + hasher.update(new_salt.as_bytes()); + let new_token_bytes = hasher.finalize(); + let new_token = base16ct::lower::encode_string(&new_token_bytes); + + let vala_api = crate::subsonic_vala::Client::with_token( + &self.obj().server_url(), + &self.obj().username(), + &new_token, + &new_salt, + ); + self.obj().emit_by_name::<()>("connected", &[&vala_api]); + } + Err(err) => { + self.obj().set_status(format!("Error: {err}")); + self.obj().set_authn_can_validate(true); + } + } + + self.obj().set_authn_can_edit(true); + } + + async fn save_async(&self) { + self.obj().set_authn_can_edit(false); + + // TODO remove unwraps etc etc + let keyring = oo7::Keyring::new().await.unwrap(); + let mut attributes = vec![("xdg:schema", crate::APP_ID.to_string())]; + // clear previous passwords + keyring.delete(&attributes).await.unwrap(); + + attributes.push(("server-url", self.obj().server_url())); + attributes.push(("username", self.obj().username())); + keyring + .create_item( + "Audrey Subsonic password", + &attributes, + self.obj().password(), + true, + ) + .await + .unwrap(); + + self.obj().set_authn_can_edit(true); + } } } +use adw::subclass::prelude::*; use gtk::glib; glib::wrapper! { - pub struct Setup(Object) + pub struct Setup(ObjectSubclass) @extends adw::PreferencesDialog, adw::Dialog, gtk::Widget, @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} - match fn { - type_ => || ffi::audrey_ui_setup_get_type(), +impl Setup { + pub fn load(&self) { + glib::spawn_future_local(glib::clone!( + #[weak(rename_to = self_)] + self, + async move { + self_.set_authn_can_edit(false); + + // TODO remove unwraps, make sure authn_can_edit is set back to true + let keyring = oo7::Keyring::new().await.unwrap(); + let attributes = vec![("xdg:schema", crate::APP_ID)]; + let items = keyring.search_items(&attributes).await.unwrap(); + + if items.is_empty() { + // didn't find shit, leave all empty + self_.set_server_url(""); + self_.set_username(""); + self_.set_password(""); + // TODO: onboarding + self_.set_authn_can_edit(true); + self_.set_authn_can_validate(true); + } else { + let item = &items[0]; + let attributes = item.attributes().await.unwrap(); + + self_.set_server_url(attributes["server-url"].clone()); + self_.set_username(attributes["username"].clone()); + // strip non-utf8 elements from the pw, i guess + self_.set_password(String::from_utf8_lossy(&item.secret().await.unwrap())); + + // first connection + self_.set_authn_can_validate(true); + self_.imp().on_authn_validate_activated().await; + } + } + )); + } +} + +mod ffi { + use glib::subclass::basic::InstanceStruct; + use glib::translate::{from_glib_none, IntoGlibPtr}; + use glib::Object; + use gtk::glib; + + type AudreyUiSetup = InstanceStruct; + + #[no_mangle] + extern "C" fn audrey_ui_setup_new() -> *mut AudreyUiSetup { + unsafe { Object::new::().into_glib_ptr() } + } + + #[no_mangle] + extern "C" fn audrey_ui_setup_load(self_: *mut AudreyUiSetup) { + let self_: &super::Setup = unsafe { &from_glib_none(self_) }; + self_.load() } } diff --git a/src/ui/setup.vala b/src/ui/setup.vala deleted file mode 100644 index 0ddca62..0000000 --- a/src/ui/setup.vala +++ /dev/null @@ -1,145 +0,0 @@ -static void salt_password (string password, out string token, out string salt) { - const int SALT_BYTES = 8; - uchar salt_bytes[SALT_BYTES]; - GCrypt.Random.randomize (salt_bytes, GCrypt.Random.Level.STRONG); - uchar salt_chars[2*SALT_BYTES+1]; - for (int i = 0; i < SALT_BYTES; i += 1) { - salt_chars[2*i+0] = "0123456789abcdef"[(salt_bytes[i]>>4)&0xf]; - salt_chars[2*i+1] = "0123456789abcdef"[(salt_bytes[i]>>0)&0xf]; - } - salt_chars[2*SALT_BYTES] = 0; - var checksum = new GLib.Checksum (ChecksumType.MD5); - checksum.update ((uchar[]) password, -1); - checksum.update (salt_chars, -1); - - token = checksum.get_string (); - salt = (string) salt_chars; -} - -[GtkTemplate (ui = "/eu/callcc/audrey/setup.ui")] -public class Audrey.Ui.Setup : Adw.PreferencesDialog { - public string status { get; private set; default = _("Not connected"); } - - public bool authn_can_edit { get; private set; default = true; } - public bool authn_can_validate { get; private set; default = false; } - - public string server_url { get; set; default = ""; } - public string username { get; set; default = ""; } - public string password { get; set; default = ""; } - public string token; - public string salt; - - public signal void connected (Subsonic.Client api); - - private static Secret.Schema secret_schema = new Secret.Schema ( - "eu.callcc.audrey", - Secret.SchemaFlags.NONE, - "server-url", Secret.SchemaAttributeType.STRING, - "username", Secret.SchemaAttributeType.STRING - ); - - [GtkCallback] private void on_authn_changed () { - this.authn_can_validate = true; - } - - [GtkCallback] private void on_authn_validate_activated () { - this.authn_can_validate = false; - this.authn_can_edit = false; - this.status = _("Connecting..."); - - string new_token, new_salt; - salt_password (this.password, out new_token, out new_salt); - var api = new Subsonic.Client.with_token ( - this.server_url, - this.username, - new_token, - new_salt); - - api.ping.begin ((obj, res) => { - try { - api.ping.end (res); - this.status = _("Connected"); - this.token = new_token; - this.salt = new_salt; - this.save (); - - this.connected (api); - } catch (Error e) { - this.status = @"$(_("Ping failed")): $(e.message)"; - this.authn_can_validate = true; - } - - this.authn_can_edit = true; - }); - } - - public void load () { - this.authn_can_edit = false; - Secret.password_searchv.begin ( - secret_schema, - new HashTable (null, null), - Secret.SearchFlags.UNLOCK, - null, - (obj, res) => { - try { - var list = Secret.password_searchv.end (res); - if (list == null) { - // didn't find shit, leave all empty - this.server_url = ""; - this.username = ""; - this.password = ""; - // TODO: onboarding - this.authn_can_edit = true; - this.authn_can_validate = true; - } else { - var first = list.data; - assert (first != null); - - this.server_url = first.attributes["server-url"]; - this.username = first.attributes["username"]; - - first.retrieve_secret.begin (null, (obj, res) => { - try { - var value = first.retrieve_secret.end (res); - this.password = value.get_text () ?? ""; - } catch (Error e) { - error ("could not retrieve password from credentials: %s", e.message); - } - - // first connection - this.authn_can_validate = true; - this.on_authn_validate_activated (); - }); - } - } catch (Error e) { - error ("could not search for password in keyring: %s", e.message); - } - }); - } - - private async void save_internal () { - this.authn_can_edit = false; - - try { - yield Secret.password_clear (secret_schema, null); - } catch (Error e) { - error ("could not clear previous passwords from keyring: %s", e.message); - } - - try { - yield Secret.password_store (secret_schema, null, "Audrey Subsonic password", this.password, null, "server-url", this.server_url, "username", this.username); - } catch (Error e) { - error ("could not store password in keyring: %s", e.message); - } - - this.authn_can_edit = true; - } - - public void save () { - this.save_internal.begin (); - } - - ~Setup () { - debug ("destroying setup dialog"); - } -}