mirror of
https://github.com/johrpan/musicus.git
synced 2025-10-26 11:47:25 +01:00
Add HTTP client and login support
This commit is contained in:
parent
d20d80d1ac
commit
ea3bd35ffd
16 changed files with 832 additions and 25 deletions
|
|
@ -1,13 +1,23 @@
|
|||
use super::database::*;
|
||||
use super::secure;
|
||||
use crate::database::*;
|
||||
use crate::player::*;
|
||||
use anyhow::{anyhow, Result};
|
||||
use futures_channel::oneshot::Sender;
|
||||
use futures_channel::{mpsc, oneshot};
|
||||
use gio::prelude::*;
|
||||
use serde::Serialize;
|
||||
use std::cell::RefCell;
|
||||
use std::path::PathBuf;
|
||||
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 {
|
||||
NoMusicLibrary,
|
||||
Loading,
|
||||
|
|
@ -50,6 +60,10 @@ pub struct Backend {
|
|||
state_sender: RefCell<mpsc::Sender<BackendState>>,
|
||||
action_sender: RefCell<Option<std::sync::mpsc::Sender<BackendAction>>>,
|
||||
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>>,
|
||||
player: RefCell<Option<Rc<Player>>>,
|
||||
}
|
||||
|
|
@ -57,13 +71,19 @@ pub struct Backend {
|
|||
impl Backend {
|
||||
pub fn new() -> Self {
|
||||
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 {
|
||||
state_stream: RefCell::new(state_stream),
|
||||
state_sender: RefCell::new(state_sender),
|
||||
action_sender: RefCell::new(None),
|
||||
settings: gio::Settings::new("de.johrpan.musicus"),
|
||||
secrets,
|
||||
music_library_path: RefCell::new(None),
|
||||
server_url: RefCell::new(None),
|
||||
login_data: RefCell::new(None),
|
||||
token: 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 !path.is_empty() {
|
||||
let context = glib::MainContext::default();
|
||||
let clone = self.clone();
|
||||
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
|
||||
.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<()> {
|
||||
|
|
@ -270,6 +302,40 @@ impl Backend {
|
|||
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>> {
|
||||
self.player.borrow().clone()
|
||||
}
|
||||
31
src/backend/client.rs
Normal file
31
src/backend/client.rs
Normal 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
7
src/backend/mod.rs
Normal 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
108
src/backend/secure.rs
Normal 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)
|
||||
}
|
||||
88
src/dialogs/login_dialog.rs
Normal file
88
src/dialogs/login_dialog.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,9 @@ pub use instrument_editor::*;
|
|||
pub mod instrument_selector;
|
||||
pub use instrument_selector::*;
|
||||
|
||||
pub mod login_dialog;
|
||||
pub use login_dialog::*;
|
||||
|
||||
pub mod person_editor;
|
||||
pub use person_editor::*;
|
||||
|
||||
|
|
@ -22,6 +25,9 @@ pub use person_selector::*;
|
|||
pub mod preferences;
|
||||
pub use preferences::*;
|
||||
|
||||
pub mod server_dialog;
|
||||
pub use server_dialog::*;
|
||||
|
||||
pub mod recording;
|
||||
pub use recording::*;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
use super::{LoginDialog, ServerDialog};
|
||||
use crate::backend::Backend;
|
||||
use gettextrs::gettext;
|
||||
use glib::clone;
|
||||
|
|
@ -6,47 +7,98 @@ use gtk_macros::get_widget;
|
|||
use libhandy::prelude::*;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// A dialog for configuring the app.
|
||||
pub struct Preferences {
|
||||
backend: Rc<Backend>,
|
||||
window: libhandy::Window,
|
||||
music_library_path_row: libhandy::ActionRow,
|
||||
url_row: libhandy::ActionRow,
|
||||
login_row: libhandy::ActionRow,
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
get_widget!(builder, libhandy::Window, window);
|
||||
get_widget!(builder, libhandy::ActionRow, music_library_path_row);
|
||||
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));
|
||||
|
||||
if let Some(path) = backend.get_music_library_path() {
|
||||
music_library_path_row.set_subtitle(Some(path.to_str().unwrap()));
|
||||
let this = Rc::new(Self {
|
||||
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(
|
||||
clone!(@strong window, @strong backend, @strong music_library_path_row => move |_| {
|
||||
let dialog = gtk::FileChooserNative::new(
|
||||
Some(&gettext("Select music library folder")),
|
||||
Some(&window), gtk::FileChooserAction::SelectFolder,None, None);
|
||||
if let Some(url) = this.backend.get_server_url() {
|
||||
this.url_row.set_subtitle(Some(&url));
|
||||
}
|
||||
|
||||
if let gtk::ResponseType::Accept = dialog.run() {
|
||||
if let Some(path) = dialog.get_filename() {
|
||||
music_library_path_row.set_subtitle(Some(path.to_str().unwrap()));
|
||||
if let Some(data) = this.backend.get_login_data() {
|
||||
this.login_row.set_subtitle(Some(&data.username));
|
||||
}
|
||||
|
||||
let context = glib::MainContext::default();
|
||||
let backend = backend.clone();
|
||||
context.spawn_local(async move {
|
||||
backend.set_music_library_path(path).await.unwrap();
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
Self { window }
|
||||
this
|
||||
}
|
||||
|
||||
/// Show the preferences dialog.
|
||||
pub fn show(&self) {
|
||||
self.window.show();
|
||||
}
|
||||
|
|
|
|||
65
src/dialogs/server_dialog.rs
Normal file
65
src/dialogs/server_dialog.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -33,6 +33,10 @@ run_command(
|
|||
)
|
||||
|
||||
sources = files(
|
||||
'backend/backend.rs',
|
||||
'backend/client.rs',
|
||||
'backend/mod.rs',
|
||||
'backend/secure.rs',
|
||||
'database/database.rs',
|
||||
'database/mod.rs',
|
||||
'database/models.rs',
|
||||
|
|
@ -43,10 +47,12 @@ sources = files(
|
|||
'dialogs/ensemble_selector.rs',
|
||||
'dialogs/instrument_editor.rs',
|
||||
'dialogs/instrument_selector.rs',
|
||||
'dialogs/login_dialog.rs',
|
||||
'dialogs/mod.rs',
|
||||
'dialogs/person_editor.rs',
|
||||
'dialogs/person_selector.rs',
|
||||
'dialogs/preferences.rs',
|
||||
'dialogs/server_dialog.rs',
|
||||
'dialogs/recording/mod.rs',
|
||||
'dialogs/recording/performance_editor.rs',
|
||||
'dialogs/recording/recording_dialog.rs',
|
||||
|
|
@ -78,7 +84,6 @@ sources = files(
|
|||
'widgets/player_bar.rs',
|
||||
'widgets/poe_list.rs',
|
||||
'widgets/selector_row.rs',
|
||||
'backend.rs',
|
||||
'config.rs',
|
||||
'config.rs.in',
|
||||
'main.rs',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue