Compare commits

...

2 commits

Author SHA1 Message Date
a027922b0e impl Error for subsonic error and one other thing 2024-11-02 11:14:38 +01:00
bc9b61aac0 translate setup dialog 2024-11-02 10:50:12 +01:00
9 changed files with 273 additions and 183 deletions

View file

@ -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;
}
}
}

View file

@ -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()
}

View file

@ -3,7 +3,6 @@ audrey_sources = [
'playbin.vala',
'rust.vapi',
'subsonic.vala',
'ui/setup.vala',
'ui/window.vala',
]

View file

@ -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);

View file

@ -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 ();
}
}

View file

@ -18,6 +18,28 @@ 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 std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::UrlParseError(err) => err.source(),
Self::ReqwestError(err) => err.source(),
_ => None,
}
}
}
impl From<reqwest::Error> for Error {
fn from(err: reqwest::Error) -> Self {
// don't print secret salt/token combo
@ -71,7 +93,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 {

View file

@ -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,
))
}
}
}

View file

@ -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<String>,
#[property(get, set)]
authn_can_edit: Cell<bool>,
#[property(get, set)]
authn_can_validate: Cell<bool>,
#[property(get, set)]
server_url: RefCell<String>,
#[property(get, set)]
username: RefCell<String>,
#[property(get, set)]
password: RefCell<String>,
}
#[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<Self>) {
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<Vec<Signal>> = 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().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::<String>()
}
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(&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<ffi::AudreyUiSetup, ffi::AudreyUiSetupClass>)
pub struct Setup(ObjectSubclass<imp::Setup>)
@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<super::imp::Setup>;
#[no_mangle]
extern "C" fn audrey_ui_setup_new() -> *mut AudreyUiSetup {
unsafe { Object::new::<super::Setup>().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()
}
}

View file

@ -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<string, string> (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");
}
}