Add HTTP client and login support

This commit is contained in:
Elias Projahn 2020-11-14 22:32:21 +01:00
parent d20d80d1ac
commit ea3bd35ffd
16 changed files with 832 additions and 25 deletions

View file

@ -18,6 +18,10 @@ gtk = { version = "0.9.2", features = ["v3_24"] }
gtk-macros = "0.2.0" gtk-macros = "0.2.0"
gstreamer = "0.16.4" gstreamer = "0.16.4"
gstreamer-player = "0.16.3" gstreamer-player = "0.16.3"
isahc = "0.9.12"
libhandy = "0.7.0" libhandy = "0.7.0"
pango = "0.9.1" pango = "0.9.1"
rand = "0.7.3" rand = "0.7.3"
secret-service = "1.1.1"
serde = { version = "1.0.117", features = ["derive"] }
serde_json = "1.0.59"

View file

@ -5,5 +5,9 @@
<default>""</default> <default>""</default>
<summary>Path to the music library folder</summary> <summary>Path to the music library folder</summary>
</key> </key>
<key name="server-url" type="s">
<default>"https://musicus.johrpan.de"</default>
<summary>URL of the Musicus server to use</summary>
</key>
</schema> </schema>
</schemalist> </schemalist>

View file

@ -4,10 +4,12 @@ project('musicus', 'rust',
license: 'AGPLv3+', license: 'AGPLv3+',
) )
dependency('dbus-1', version: '>= 1.3')
dependency('glib-2.0', version: '>= 2.56') dependency('glib-2.0', version: '>= 2.56')
dependency('gio-2.0', version: '>= 2.56') dependency('gio-2.0', version: '>= 2.56')
dependency('gstreamer-1.0', version: '>= 1.12') dependency('gstreamer-1.0', version: '>= 1.12')
dependency('gtk+-3.0', version: '>= 3.24.7') dependency('gtk+-3.0', version: '>= 3.24.7')
dependency('libcurl', version: '>= 7.24.0')
dependency('libhandy-1', version: '>= 1.0.0') dependency('libhandy-1', version: '>= 1.0.0')
dependency('pango', version: '>= 1.0') dependency('pango', version: '>= 1.0')
dependency('sqlite3', version: '>= 3.20') dependency('sqlite3', version: '>= 3.20')

View file

@ -6,6 +6,7 @@
<file preprocess="xml-stripblanks">ui/ensemble_screen.ui</file> <file preprocess="xml-stripblanks">ui/ensemble_screen.ui</file>
<file preprocess="xml-stripblanks">ui/instrument_editor.ui</file> <file preprocess="xml-stripblanks">ui/instrument_editor.ui</file>
<file preprocess="xml-stripblanks">ui/instrument_selector.ui</file> <file preprocess="xml-stripblanks">ui/instrument_selector.ui</file>
<file preprocess="xml-stripblanks">ui/login_dialog.ui</file>
<file preprocess="xml-stripblanks">ui/part_editor.ui</file> <file preprocess="xml-stripblanks">ui/part_editor.ui</file>
<file preprocess="xml-stripblanks">ui/performance_editor.ui</file> <file preprocess="xml-stripblanks">ui/performance_editor.ui</file>
<file preprocess="xml-stripblanks">ui/person_editor.ui</file> <file preprocess="xml-stripblanks">ui/person_editor.ui</file>
@ -21,6 +22,7 @@
<file preprocess="xml-stripblanks">ui/recording_selector.ui</file> <file preprocess="xml-stripblanks">ui/recording_selector.ui</file>
<file preprocess="xml-stripblanks">ui/recording_selector_screen.ui</file> <file preprocess="xml-stripblanks">ui/recording_selector_screen.ui</file>
<file preprocess="xml-stripblanks">ui/section_editor.ui</file> <file preprocess="xml-stripblanks">ui/section_editor.ui</file>
<file preprocess="xml-stripblanks">ui/server_dialog.ui</file>
<file preprocess="xml-stripblanks">ui/tracks_editor.ui</file> <file preprocess="xml-stripblanks">ui/tracks_editor.ui</file>
<file preprocess="xml-stripblanks">ui/track_editor.ui</file> <file preprocess="xml-stripblanks">ui/track_editor.ui</file>
<file preprocess="xml-stripblanks">ui/window.ui</file> <file preprocess="xml-stripblanks">ui/window.ui</file>

219
res/ui/login_dialog.ui Normal file
View file

@ -0,0 +1,219 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.38.1 -->
<interface>
<requires lib="gtk+" version="3.24"/>
<requires lib="libhandy" version="0.0"/>
<object class="HdyWindow" id="window">
<property name="can-focus">False</property>
<property name="modal">True</property>
<property name="default-width">350</property>
<property name="type-hint">dialog</property>
<child>
<object class="GtkStack" id="stack">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="transition-type">crossfade</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="HdyHeaderBar">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="title" translatable="yes">Login</property>
<child>
<object class="GtkButton" id="cancel_button">
<property name="label" translatable="yes">Cancel</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
</object>
</child>
<child>
<object class="GtkButton" id="login_button">
<property name="label" translatable="yes">Login</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="can-default">True</property>
<property name="has-default">True</property>
<property name="receives-default">False</property>
<style>
<class name="suggested-action"/>
</style>
</object>
<packing>
<property name="pack-type">end</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkInfoBar" id="info_bar">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="message-type">error</property>
<property name="revealed">False</property>
<child internal-child="action_area">
<object class="GtkButtonBox">
<property name="can-focus">False</property>
<property name="spacing">6</property>
<property name="layout-style">end</property>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child internal-child="content_area">
<object class="GtkBox">
<property name="can-focus">False</property>
<property name="spacing">16</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="label" translatable="yes">The login credentials were wrong!</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<!-- n-columns=2 n-rows=2 -->
<object class="GtkGrid">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="border-width">18</property>
<property name="row-spacing">12</property>
<property name="column-spacing">6</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">end</property>
<property name="label" translatable="yes">Username</property>
</object>
<packing>
<property name="left-attach">0</property>
<property name="top-attach">0</property>
</packing>
</child>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">end</property>
<property name="label" translatable="yes">Password</property>
</object>
<packing>
<property name="left-attach">0</property>
<property name="top-attach">1</property>
</packing>
</child>
<child>
<object class="GtkEntry" id="username_entry">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="has-focus">True</property>
<property name="hexpand">True</property>
</object>
<packing>
<property name="left-attach">1</property>
<property name="top-attach">0</property>
</packing>
</child>
<child>
<object class="GtkEntry" id="password_entry">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="visibility">False</property>
<property name="activates-default">True</property>
<property name="input-purpose">password</property>
</object>
<packing>
<property name="left-attach">1</property>
<property name="top-attach">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
<packing>
<property name="name">content</property>
</packing>
</child>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="HdyHeaderBar">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="title" translatable="yes">Login</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkSpinner">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="active">True</property>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="name">loading</property>
<property name="position">1</property>
</packing>
</child>
<child>
<placeholder/>
</child>
</object>
</child>
</object>
</interface>

View file

@ -40,7 +40,59 @@
</child> </child>
</object> </object>
</child> </child>
<child>
<object class="HdyPreferencesGroup">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="title" translatable="yes">Server connection</property>
<child>
<object class="HdyActionRow" id="url_row">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="selectable">False</property>
<property name="title" translatable="yes">Server URL</property>
<property name="activatable-widget">url_button</property>
<property name="subtitle" translatable="yes">Not set</property>
<child>
<object class="GtkButton" id="url_button">
<property name="label" translatable="yes">Change</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="valign">center</property>
</object>
</child>
</object>
</child>
<child>
<object class="HdyActionRow" id="login_row">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="selectable">False</property>
<property name="title" translatable="yes">Login credentials</property>
<property name="activatable-widget">login_button</property>
<property name="subtitle" translatable="yes">Not logged in</property>
<child>
<object class="GtkButton" id="login_button">
<property name="label" translatable="yes">Change</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
<property name="valign">center</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object> </object>
</child> </child>
</object> </object>
<object class="GtkSizeGroup">
<widgets>
<widget name="select_music_library_path_button"/>
<widget name="url_button"/>
<widget name="login_button"/>
</widgets>
</object>
</interface> </interface>

96
res/ui/server_dialog.ui Normal file
View file

@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.38.1 -->
<interface>
<requires lib="gtk+" version="3.24"/>
<requires lib="libhandy" version="0.0"/>
<object class="HdyWindow" id="window">
<property name="can-focus">False</property>
<property name="modal">True</property>
<property name="destroy-with-parent">True</property>
<property name="type-hint">dialog</property>
<child>
<object class="GtkBox">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="HdyHeaderBar">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="title" translatable="yes">Server</property>
<child>
<object class="GtkButton" id="cancel_button">
<property name="label" translatable="yes">Cancel</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="receives-default">True</property>
</object>
</child>
<child>
<object class="GtkButton" id="set_button">
<property name="label" translatable="yes">Set</property>
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="can-default">True</property>
<property name="has-default">True</property>
<property name="receives-default">True</property>
<style>
<class name="suggested-action"/>
</style>
</object>
<packing>
<property name="pack-type">end</property>
<property name="position">1</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
<child>
<!-- n-columns=2 n-rows=1 -->
<object class="GtkGrid">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="border-width">18</property>
<property name="row-spacing">12</property>
<property name="column-spacing">6</property>
<child>
<object class="GtkLabel">
<property name="visible">True</property>
<property name="can-focus">False</property>
<property name="halign">end</property>
<property name="label" translatable="yes">URL</property>
</object>
<packing>
<property name="left-attach">0</property>
<property name="top-attach">0</property>
</packing>
</child>
<child>
<object class="GtkEntry" id="url_entry">
<property name="visible">True</property>
<property name="can-focus">True</property>
<property name="has-focus">True</property>
<property name="hexpand">True</property>
<property name="activates-default">True</property>
</object>
<packing>
<property name="left-attach">1</property>
<property name="top-attach">0</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
</object>
</child>
</object>
</interface>

View file

@ -1,13 +1,23 @@
use super::database::*; use super::secure;
use crate::database::*;
use crate::player::*; use crate::player::*;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use futures_channel::oneshot::Sender; use futures_channel::oneshot::Sender;
use futures_channel::{mpsc, oneshot}; use futures_channel::{mpsc, oneshot};
use gio::prelude::*; use gio::prelude::*;
use serde::Serialize;
use std::cell::RefCell; use std::cell::RefCell;
use std::path::PathBuf; use std::path::PathBuf;
use std::rc::Rc; use std::rc::Rc;
/// Credentials used for login.
#[derive(Serialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct LoginData {
pub username: String,
pub password: String,
}
pub enum BackendState { pub enum BackendState {
NoMusicLibrary, NoMusicLibrary,
Loading, Loading,
@ -50,6 +60,10 @@ pub struct Backend {
state_sender: RefCell<mpsc::Sender<BackendState>>, state_sender: RefCell<mpsc::Sender<BackendState>>,
action_sender: RefCell<Option<std::sync::mpsc::Sender<BackendAction>>>, action_sender: RefCell<Option<std::sync::mpsc::Sender<BackendAction>>>,
settings: gio::Settings, settings: gio::Settings,
secrets: secret_service::SecretService,
server_url: RefCell<Option<String>>,
login_data: RefCell<Option<LoginData>>,
token: RefCell<Option<String>>,
music_library_path: RefCell<Option<PathBuf>>, music_library_path: RefCell<Option<PathBuf>>,
player: RefCell<Option<Rc<Player>>>, player: RefCell<Option<Rc<Player>>>,
} }
@ -57,13 +71,19 @@ pub struct Backend {
impl Backend { impl Backend {
pub fn new() -> Self { pub fn new() -> Self {
let (state_sender, state_stream) = mpsc::channel(1024); let (state_sender, state_stream) = mpsc::channel(1024);
let secrets = secret_service::SecretService::new(secret_service::EncryptionType::Dh)
.expect("Failed to connect to SecretsService!");
Backend { Backend {
state_stream: RefCell::new(state_stream), state_stream: RefCell::new(state_stream),
state_sender: RefCell::new(state_sender), state_sender: RefCell::new(state_sender),
action_sender: RefCell::new(None), action_sender: RefCell::new(None),
settings: gio::Settings::new("de.johrpan.musicus"), settings: gio::Settings::new("de.johrpan.musicus"),
secrets,
music_library_path: RefCell::new(None), music_library_path: RefCell::new(None),
server_url: RefCell::new(None),
login_data: RefCell::new(None),
token: RefCell::new(None),
player: RefCell::new(None), player: RefCell::new(None),
} }
} }
@ -72,13 +92,25 @@ impl Backend {
if let Some(path) = self.settings.get_string("music-library-path") { if let Some(path) = self.settings.get_string("music-library-path") {
if !path.is_empty() { if !path.is_empty() {
let context = glib::MainContext::default(); let context = glib::MainContext::default();
let clone = self.clone();
context.spawn_local(async move { context.spawn_local(async move {
self.set_music_library_path_priv(PathBuf::from(path.to_string())) clone
.set_music_library_path_priv(PathBuf::from(path.to_string()))
.await .await
.unwrap(); .unwrap();
}); });
} }
} }
if let Some(data) = secure::load_login_data().unwrap() {
self.login_data.replace(Some(data));
}
if let Some(url) = self.settings.get_string("server-url") {
if !url.is_empty() {
self.server_url.replace(Some(url.to_string()));
}
}
} }
pub async fn update_person(&self, person: Person) -> Result<()> { pub async fn update_person(&self, person: Person) -> Result<()> {
@ -270,6 +302,40 @@ impl Backend {
self.music_library_path.borrow().clone() self.music_library_path.borrow().clone()
} }
/// Get the currently stored login credentials.
pub fn get_login_data(&self) -> Option<LoginData> {
self.login_data.borrow().clone()
}
/// Set the URL of the Musicus server to connect to.
pub fn set_server_url(&self, url: &str) -> Result<()> {
self.settings.set_string("server-url", url)?;
self.server_url.replace(Some(url.to_string()));
Ok(())
}
/// Get the currently used login token.
pub fn get_token(&self) -> Option<String> {
self.token.borrow().clone()
}
/// Set the login token to use. This will be done automatically by the login method.
pub fn set_token(&self, token: &str) {
self.token.replace(Some(token.to_string()));
}
/// Get the currently set server URL.
pub fn get_server_url(&self) -> Option<String> {
self.server_url.borrow().clone()
}
/// Set the user credentials to use.
pub async fn set_login_data(&self, data: LoginData) -> Result<()> {
secure::store_login_data(data.clone()).await?;
self.login_data.replace(Some(data));
Ok(())
}
pub fn get_player(&self) -> Option<Rc<Player>> { pub fn get_player(&self) -> Option<Rc<Player>> {
self.player.borrow().clone() self.player.borrow().clone()
} }

31
src/backend/client.rs Normal file
View file

@ -0,0 +1,31 @@
use super::Backend;
use anyhow::{anyhow, bail, Result};
use isahc::http::StatusCode;
use isahc::prelude::*;
impl Backend {
/// Try to login a user with the provided credentials and return, wether the login suceeded.
pub async fn login(&self) -> Result<bool> {
let server_url = self.get_server_url().ok_or(anyhow!("No server URL set!"))?;
let data = self.get_login_data().ok_or(anyhow!("No login data set!"))?;
let request = Request::post(format!("{}/login", server_url))
.header("Content-Type", "application/json")
.body(serde_json::to_string(&data)?)?;
let mut response = isahc::send_async(request).await?;
let success = match response.status() {
StatusCode::OK => {
let token = response.text_async().await?;
self.set_token(&token);
println!("{}", &token);
true
}
StatusCode::UNAUTHORIZED => false,
_ => bail!("Unexpected response status!"),
};
Ok(success)
}
}

7
src/backend/mod.rs Normal file
View file

@ -0,0 +1,7 @@
pub mod backend;
pub use backend::*;
pub mod client;
pub use client::*;
mod secure;

108
src/backend/secure.rs Normal file
View file

@ -0,0 +1,108 @@
use super::LoginData;
use anyhow::{anyhow, Result};
use futures_channel::oneshot;
use secret_service::{Collection, EncryptionType, SecretService};
/// Savely store the user's current login credentials.
pub async fn store_login_data(data: LoginData) -> Result<()> {
let (sender, receiver) = oneshot::channel::<Result<()>>();
std::thread::spawn(move || sender.send(store_login_data_priv(data)));
receiver.await?
}
/// Savely store the user's current login credentials.
fn store_login_data_priv(data: LoginData) -> Result<()> {
let ss = get_ss()?;
let collection = get_collection(&ss)?;
let key = "musicus-login-data";
delete_secrets(&collection, key)?;
collection
.create_item(
key,
vec![("username", &data.username)],
data.password.as_bytes(),
true,
"text/plain",
)
.or(Err(anyhow!(
"Failed to save login data using SecretService!"
)))?;
Ok(())
}
/// Get the login credentials from secret storage.
pub fn load_login_data() -> Result<Option<LoginData>> {
let ss = get_ss()?;
let collection = get_collection(&ss)?;
let items = collection.get_all_items().or(Err(anyhow!(
"Failed to get items from SecretService collection!"
)))?;
let key = "musicus-login-data";
let item = items
.iter()
.find(|item| item.get_label().unwrap_or_default() == key);
Ok(match item {
Some(item) => {
let attrs = item.get_attributes().or(Err(anyhow!(
"Failed to get attributes for ScretService item!"
)))?;
let username = attrs
.iter()
.find(|attr| attr.0 == "username")
.ok_or(anyhow!("No username in login data!"))?
.1
.clone();
let password = std::str::from_utf8(
&item
.get_secret()
.or(Err(anyhow!("Failed to get secret from SecretService!")))?,
)?
.to_string();
Some(LoginData { username, password })
}
None => None,
})
}
/// Delete all stored secrets for the provided key.
fn delete_secrets(collection: &Collection, key: &str) -> Result<()> {
let items = collection.get_all_items().or(Err(anyhow!(
"Failed to get items from SecretService collection!"
)))?;
for item in items {
if item.get_label().unwrap_or_default() == key {
item.delete()
.or(Err(anyhow!("Failed to delete SecretService item!")))?;
}
}
Ok(())
}
/// Get the SecretService interface.
fn get_ss() -> Result<SecretService> {
SecretService::new(EncryptionType::Dh).or(Err(anyhow!("Failed to get SecretService!")))
}
/// Get the default SecretService collection and unlock it.
fn get_collection(ss: &SecretService) -> Result<Collection> {
let collection = ss
.get_default_collection()
.or(Err(anyhow!("Failed to get SecretService connection!")))?;
collection
.unlock()
.or(Err(anyhow!("Failed to unclock SecretService collection!")))?;
Ok(collection)
}

View file

@ -0,0 +1,88 @@
use crate::backend::{Backend, LoginData};
use glib::clone;
use gtk::prelude::*;
use gtk_macros::get_widget;
use std::cell::RefCell;
use std::rc::Rc;
/// A dialog for entering login credentials.
pub struct LoginDialog {
backend: Rc<Backend>,
window: libhandy::Window,
stack: gtk::Stack,
info_bar: gtk::InfoBar,
username_entry: gtk::Entry,
password_entry: gtk::Entry,
selected_cb: RefCell<Option<Box<dyn Fn(LoginData) -> ()>>>,
}
impl LoginDialog {
/// Create a new login dialog.
pub fn new<P: IsA<gtk::Window>>(backend: Rc<Backend>, parent: &P) -> Rc<Self> {
// Create UI
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/login_dialog.ui");
get_widget!(builder, libhandy::Window, window);
get_widget!(builder, gtk::Stack, stack);
get_widget!(builder, gtk::InfoBar, info_bar);
get_widget!(builder, gtk::Button, cancel_button);
get_widget!(builder, gtk::Button, login_button);
get_widget!(builder, gtk::Entry, username_entry);
get_widget!(builder, gtk::Entry, password_entry);
window.set_transient_for(Some(parent));
let this = Rc::new(Self {
backend,
window,
stack,
info_bar,
username_entry,
password_entry,
selected_cb: RefCell::new(None),
});
// Connect signals and callbacks
cancel_button.connect_clicked(clone!(@strong this => move |_| {
this.window.close();
}));
login_button.connect_clicked(clone!(@strong this => move |_| {
this.stack.set_visible_child_name("loading");
let data = LoginData {
username: this.username_entry.get_text().to_string(),
password: this.password_entry.get_text().to_string(),
};
let c = glib::MainContext::default();
let clone = this.clone();
c.spawn_local(async move {
clone.backend.set_login_data(data.clone()).await.unwrap();
if clone.backend.login().await.unwrap() {
if let Some(cb) = &*clone.selected_cb.borrow() {
cb(data);
}
clone.window.close();
} else {
clone.stack.set_visible_child_name("content");
clone.info_bar.set_revealed(true);
}
});
}));
this
}
/// The closure to call when the login succeded.
pub fn set_selected_cb<F: Fn(LoginData) -> () + 'static>(&self, cb: F) {
self.selected_cb.replace(Some(Box::new(cb)));
}
/// Show the login dialog.
pub fn show(&self) {
self.window.show();
}
}

View file

@ -13,6 +13,9 @@ pub use instrument_editor::*;
pub mod instrument_selector; pub mod instrument_selector;
pub use instrument_selector::*; pub use instrument_selector::*;
pub mod login_dialog;
pub use login_dialog::*;
pub mod person_editor; pub mod person_editor;
pub use person_editor::*; pub use person_editor::*;
@ -22,6 +25,9 @@ pub use person_selector::*;
pub mod preferences; pub mod preferences;
pub use preferences::*; pub use preferences::*;
pub mod server_dialog;
pub use server_dialog::*;
pub mod recording; pub mod recording;
pub use recording::*; pub use recording::*;

View file

@ -1,3 +1,4 @@
use super::{LoginDialog, ServerDialog};
use crate::backend::Backend; use crate::backend::Backend;
use gettextrs::gettext; use gettextrs::gettext;
use glib::clone; use glib::clone;
@ -6,47 +7,98 @@ use gtk_macros::get_widget;
use libhandy::prelude::*; use libhandy::prelude::*;
use std::rc::Rc; use std::rc::Rc;
/// A dialog for configuring the app.
pub struct Preferences { pub struct Preferences {
backend: Rc<Backend>,
window: libhandy::Window, window: libhandy::Window,
music_library_path_row: libhandy::ActionRow,
url_row: libhandy::ActionRow,
login_row: libhandy::ActionRow,
} }
impl Preferences { impl Preferences {
pub fn new<P: IsA<gtk::Window>>(backend: Rc<Backend>, parent: &P) -> Self { /// Create a new preferences dialog.
pub fn new<P: IsA<gtk::Window>>(backend: Rc<Backend>, parent: &P) -> Rc<Self> {
// Create UI
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/preferences.ui"); let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/preferences.ui");
get_widget!(builder, libhandy::Window, window); get_widget!(builder, libhandy::Window, window);
get_widget!(builder, libhandy::ActionRow, music_library_path_row); get_widget!(builder, libhandy::ActionRow, music_library_path_row);
get_widget!(builder, gtk::Button, select_music_library_path_button); get_widget!(builder, gtk::Button, select_music_library_path_button);
get_widget!(builder, libhandy::ActionRow, url_row);
get_widget!(builder, gtk::Button, url_button);
get_widget!(builder, libhandy::ActionRow, login_row);
get_widget!(builder, gtk::Button, login_button);
window.set_transient_for(Some(parent)); window.set_transient_for(Some(parent));
if let Some(path) = backend.get_music_library_path() { let this = Rc::new(Self {
music_library_path_row.set_subtitle(Some(path.to_str().unwrap())); backend,
window,
music_library_path_row,
url_row,
login_row,
});
// Connect signals and callbacks
select_music_library_path_button.connect_clicked(clone!(@strong this => move |_| {
let dialog = gtk::FileChooserNative::new(
Some(&gettext("Select music library folder")),
Some(&this.window), gtk::FileChooserAction::SelectFolder,None, None);
if let gtk::ResponseType::Accept = dialog.run() {
if let Some(path) = dialog.get_filename() {
this.music_library_path_row.set_subtitle(Some(path.to_str().unwrap()));
let context = glib::MainContext::default();
let backend = this.backend.clone();
context.spawn_local(async move {
backend.set_music_library_path(path).await.unwrap();
});
}
}
}));
url_button.connect_clicked(clone!(@strong this => move |_| {
let dialog = ServerDialog::new(this.backend.clone(), &this.window);
dialog.set_selected_cb(clone!(@strong this => move |url| {
this.url_row.set_subtitle(Some(&url));
}));
dialog.show();
}));
login_button.connect_clicked(clone!(@strong this => move |_| {
let dialog = LoginDialog::new(this.backend.clone(), &this.window);
dialog.set_selected_cb(clone!(@strong this => move |data| {
this.login_row.set_subtitle(Some(&data.username));
}));
dialog.show();
}));
// Initialize
if let Some(path) = this.backend.get_music_library_path() {
this.music_library_path_row
.set_subtitle(Some(path.to_str().unwrap()));
} }
select_music_library_path_button.connect_clicked( if let Some(url) = this.backend.get_server_url() {
clone!(@strong window, @strong backend, @strong music_library_path_row => move |_| { this.url_row.set_subtitle(Some(&url));
let dialog = gtk::FileChooserNative::new( }
Some(&gettext("Select music library folder")),
Some(&window), gtk::FileChooserAction::SelectFolder,None, None);
if let gtk::ResponseType::Accept = dialog.run() { if let Some(data) = this.backend.get_login_data() {
if let Some(path) = dialog.get_filename() { this.login_row.set_subtitle(Some(&data.username));
music_library_path_row.set_subtitle(Some(path.to_str().unwrap())); }
let context = glib::MainContext::default(); this
let backend = backend.clone();
context.spawn_local(async move {
backend.set_music_library_path(path).await.unwrap();
});
}
}
}),
);
Self { window }
} }
/// Show the preferences dialog.
pub fn show(&self) { pub fn show(&self) {
self.window.show(); self.window.show();
} }

View file

@ -0,0 +1,65 @@
use crate::backend::Backend;
use glib::clone;
use gtk::prelude::*;
use gtk_macros::get_widget;
use std::cell::RefCell;
use std::rc::Rc;
/// A dialog for setting up the server.
pub struct ServerDialog {
backend: Rc<Backend>,
window: libhandy::Window,
url_entry: gtk::Entry,
selected_cb: RefCell<Option<Box<dyn Fn(String) -> ()>>>,
}
impl ServerDialog {
/// Create a new server dialog.
pub fn new<P: IsA<gtk::Window>>(backend: Rc<Backend>, parent: &P) -> Rc<Self> {
// Create UI
let builder = gtk::Builder::from_resource("/de/johrpan/musicus/ui/server_dialog.ui");
get_widget!(builder, libhandy::Window, window);
get_widget!(builder, gtk::Button, cancel_button);
get_widget!(builder, gtk::Button, set_button);
get_widget!(builder, gtk::Entry, url_entry);
window.set_transient_for(Some(parent));
let this = Rc::new(Self {
backend,
window,
url_entry,
selected_cb: RefCell::new(None),
});
// Connect signals and callbacks
cancel_button.connect_clicked(clone!(@strong this => move |_| {
this.window.close();
}));
set_button.connect_clicked(clone!(@strong this => move |_| {
let url = this.url_entry.get_text().to_string();
this.backend.set_server_url(&url).unwrap();
if let Some(cb) = &*this.selected_cb.borrow() {
cb(url);
}
this.window.close();
}));
this
}
/// The closure to call when the server was set.
pub fn set_selected_cb<F: Fn(String) -> () + 'static>(&self, cb: F) {
self.selected_cb.replace(Some(Box::new(cb)));
}
/// Show the server dialog.
pub fn show(&self) {
self.window.show();
}
}

View file

@ -33,6 +33,10 @@ run_command(
) )
sources = files( sources = files(
'backend/backend.rs',
'backend/client.rs',
'backend/mod.rs',
'backend/secure.rs',
'database/database.rs', 'database/database.rs',
'database/mod.rs', 'database/mod.rs',
'database/models.rs', 'database/models.rs',
@ -43,10 +47,12 @@ sources = files(
'dialogs/ensemble_selector.rs', 'dialogs/ensemble_selector.rs',
'dialogs/instrument_editor.rs', 'dialogs/instrument_editor.rs',
'dialogs/instrument_selector.rs', 'dialogs/instrument_selector.rs',
'dialogs/login_dialog.rs',
'dialogs/mod.rs', 'dialogs/mod.rs',
'dialogs/person_editor.rs', 'dialogs/person_editor.rs',
'dialogs/person_selector.rs', 'dialogs/person_selector.rs',
'dialogs/preferences.rs', 'dialogs/preferences.rs',
'dialogs/server_dialog.rs',
'dialogs/recording/mod.rs', 'dialogs/recording/mod.rs',
'dialogs/recording/performance_editor.rs', 'dialogs/recording/performance_editor.rs',
'dialogs/recording/recording_dialog.rs', 'dialogs/recording/recording_dialog.rs',
@ -78,7 +84,6 @@ sources = files(
'widgets/player_bar.rs', 'widgets/player_bar.rs',
'widgets/poe_list.rs', 'widgets/poe_list.rs',
'widgets/selector_row.rs', 'widgets/selector_row.rs',
'backend.rs',
'config.rs', 'config.rs',
'config.rs.in', 'config.rs.in',
'main.rs', 'main.rs',