diff --git a/Cargo.lock b/Cargo.lock index b03880c..fc15b34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2372,6 +2372,8 @@ dependencies = [ "slint", "slint-build", "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", "xdg", ] @@ -5263,7 +5265,6 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2 0.6.1", diff --git a/gui/Cargo.toml b/gui/Cargo.toml index 930a321..4907baa 100644 --- a/gui/Cargo.toml +++ b/gui/Cargo.toml @@ -3,15 +3,26 @@ name = "inkopslista" version = "0.0.0" edition = "2024" +[lib] +path = "src/lib.rs" +crate-type = ["cdylib", "lib"] + [dependencies] reqwest = { version = "0.12.26", features = ["json", "rustls-tls"], default-features = false } inkopslista-lib = { path = "../lib" } -tokio = { version = "1.48.0", features = ["full"] } +tokio = { version = "1.48.0", default-features = false, features = ["rt", "macros", "sync"] } serde = { version = "1.0.228", features = ["derive"] } -xdg = "3.0.0" serde_json = "1.0.148" anyhow = "1.0.100" +[target.'cfg(target_os = "linux")'.dependencies] +tokio = { version = "1.48.0", default-features = false, features = ["fs", "rt-multi-thread"] } +xdg = "3.0.0" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = "0.2.106" +wasm-bindgen-futures = "0.4.56" + [dependencies.slint] version = "1.14.1" default-features = false diff --git a/gui/index.html b/gui/index.html new file mode 100644 index 0000000..4141037 --- /dev/null +++ b/gui/index.html @@ -0,0 +1,11 @@ + + + + + + + diff --git a/gui/src/config.rs b/gui/src/config.rs index 18ad9f3..96336db 100644 --- a/gui/src/config.rs +++ b/gui/src/config.rs @@ -1,9 +1,6 @@ -use anyhow::{Context, Ok}; use serde::{Deserialize, Serialize}; -use serde_json; #[derive(Serialize, Deserialize)] - pub struct Config { pub address: String, } @@ -11,42 +8,54 @@ pub struct Config { impl Default for Config { fn default() -> Self { Self { - address: "http://localhost:8000".to_string(), + address: "https://inkopslista.nubo.sh".to_string(), } } } -const CONFIG_FILE_NAME: &'static str = "config.json"; -const XDG_PREFIX: &'static str = "inköpslista"; -pub fn save_config(address: String) -> Result<(), anyhow::Error> { - let xdg_dirs = xdg::BaseDirectories::with_prefix(XDG_PREFIX); - let conf = Config { address: address }; - let conf = serde_json::to_string(&conf).context("unable to serialize config")?; +#[cfg(not(target_arch = "wasm32"))] +pub use fs::*; - let path = xdg_dirs - .place_config_file(CONFIG_FILE_NAME) - .context("unable to create config directory")?; +#[cfg(not(target_arch = "wasm32"))] +mod fs { + use super::Config; + use anyhow::{Context, Ok}; + use serde_json; - tokio::spawn(async { - let res = tokio::fs::write(path, conf) + const CONFIG_FILE_NAME: &'static str = "config.json"; + const XDG_PREFIX: &'static str = "inköpslista"; + + pub fn save_config(address: String) -> Result<(), anyhow::Error> { + let xdg_dirs = xdg::BaseDirectories::with_prefix(XDG_PREFIX); + let conf = Config { address: address }; + let conf = serde_json::to_string(&conf).context("unable to serialize config")?; + + let path = xdg_dirs + .place_config_file(CONFIG_FILE_NAME) + .context("unable to create config directory")?; + + tokio::spawn(async { + let res = tokio::fs::write(path, conf) + .await + .context("unable to write config file"); + if let Err(err) = res { + eprintln!("{err:?}"); + } + }); + Ok(()) + } + + pub async fn get_config() -> Result { + let xdg_dirs = xdg::BaseDirectories::with_prefix(XDG_PREFIX); + let file = xdg_dirs + .get_config_file(CONFIG_FILE_NAME) + .context("no config file")?; + let content = tokio::fs::read_to_string(file) .await - .context("unable to write config file"); - if let Err(err) = res { - eprintln!("{err:?}"); - } - }); - Ok(()) -} + .context("failed to read config file")?; -pub async fn get_config() -> Result { - let xdg_dirs = xdg::BaseDirectories::with_prefix(XDG_PREFIX); - let file = xdg_dirs - .get_config_file(CONFIG_FILE_NAME) - .context("no config file")?; - let content = tokio::fs::read_to_string(file) - .await - .context("failed to read config file")?; - - let conf: Config = serde_json::from_str(&content).context("Unable to deserealize contect")?; - Ok(conf) + let conf: Config = + serde_json::from_str(&content).context("Unable to deserealize contect")?; + Ok(conf) + } } diff --git a/gui/src/lib.rs b/gui/src/lib.rs new file mode 100644 index 0000000..d2f5c13 --- /dev/null +++ b/gui/src/lib.rs @@ -0,0 +1,207 @@ +// Prevent console window in addition to Slint window in Windows release builds when, e.g., starting the app via file manager. Ignored on other platforms. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod config; +mod runtime; +use std::{ + rc::Rc, + sync::{Arc, Mutex}, +}; + +use inkopslista_lib::{Item, ItemData}; +use reqwest::Url; +use slint::{Model, ModelRc, ToSharedString, VecModel}; + +use crate::{config::Config, runtime::spawn}; + +slint::include_modules!(); + +/// GET "/api/list" from the server +async fn get_list(conf: &Mutex) -> Result, anyhow::Error> { + let base = Url::parse(&conf.lock().unwrap().address)?; + let url = base.join("/api/list")?; + let list = reqwest::get(url).await?.json::>().await?; + Ok(list) +} + +/// Add or update an item in the list +async fn put_list(item: &str, data: &ItemData, conf: &Mutex) -> Result<(), anyhow::Error> { + let client = reqwest::Client::new(); + let base = Url::parse(&conf.lock().unwrap().address)?.join("/api/list/")?; + let url = base.join(item)?; + let _response = client + .put(url) + .json(data) + .send() + .await? + .error_for_status()?; + Ok(()) +} + +async fn delete_item(item: &str, conf: &Mutex) -> Result<(), anyhow::Error> { + let client = reqwest::Client::new(); + let base = Url::parse(&conf.lock().unwrap().address)?.join("/api/list/")?; + let url = base.join(item)?; + let _response = client.delete(url).send().await?.error_for_status()?; + Ok(()) +} + +/// Convert shopping list to slint types +fn update_slint_list(new_list: Vec, list: &VecModel) { + list.clear(); + new_list + .iter() + .map(|item| SItem { + name: item.name.to_shared_string(), + checked: item.data.checked, + }) + .for_each(|item| list.push(item)); +} + +fn refresh_list(conf: &Arc>, app_weak: &slint::Weak) { + let conf = conf.clone(); + let app_weak = app_weak.clone(); + spawn(async move { + let new_list = match get_list(&conf).await { + Ok(new_list) => new_list, + Err(e) => { + eprintln!("{e:?}"); + return; + } + }; + let _ = app_weak.upgrade_in_event_loop(|app| { + let list: ModelRc = app.global::().get_list(); + let list: &VecModel = list.as_any().downcast_ref().expect("list is a VecModel"); + update_slint_list(new_list, &list); + }); + }); +} + +#[cfg(target_arch = "wasm32")] +#[cfg_attr(target_arch = "wasm32", wasm_bindgen::prelude::wasm_bindgen(start))] +pub fn main() { + if let Err(e) = run() { + eprintln!("{e:?}"); + } +} + +pub fn run() -> Result<(), anyhow::Error> { + let app = App::new()?; + let state = app.global::(); + let list_shared = Rc::new(VecModel::default()); + state.set_list(ModelRc::new(list_shared.clone())); + + #[cfg(not(target_arch = "wasm32"))] + let conf = runtime::RT + .block_on(config::get_config()) + .inspect_err(|err| eprintln!("{err:?}")) + .unwrap_or_default(); + + #[cfg(target_arch = "wasm32")] + let conf = Config::default(); + + state.set_server_address(conf.address.to_shared_string()); + let conf_shared = Arc::new(Mutex::new(conf)); + + let conf = conf_shared.clone(); + let app_weak = app.as_weak(); + // load shopping list from server on start + refresh_list(&conf, &app_weak); + state.on_refresh_list(move || { + refresh_list(&conf, &app_weak); + }); + + let conf = conf_shared.clone(); + let list = list_shared.clone(); + state.on_add_to_list(move |item| { + if list.iter().any(|item2| item2.name == item) { + return; + // TODO: implement increment when number is added + } + list.push(SItem { + checked: false, + name: item.clone(), + }); + let conf = conf.clone(); + spawn(async move { + // TODO: add number to increment + let res = put_list( + &item, + &ItemData { + checked: false, + amount: 1, + }, + &conf, + ) + .await; + if let Err(err) = res { + eprintln!("{err:?}"); + } + }); + }); + + let conf = conf_shared.clone(); + let list = list_shared.clone(); + state.on_check_item(move |item| { + let positem = list + .iter() + .enumerate() + .find(|(_, item2)| item2.name == item); + let (pos, mut item) = match positem { + Some(positem) => positem, + None => return, + }; + item.checked = !item.checked; + list.set_row_data(pos, item.clone()); + let conf = conf.clone(); + + spawn(async move { + let res = put_list( + &item.name, + &ItemData { + checked: item.checked, + amount: 1, + }, + &conf, + ) + .await; + if let Err(err) = res { + eprintln!("{err:?}"); + } + }); + }); + + let conf = conf_shared.clone(); + let list = list_shared.clone(); + state.on_delete_checked(move || { + let poss: Vec<_> = list + .iter() + .enumerate() + .filter(|(_, item)| item.checked) + .map(|(pos, item)| (pos, item.name)) + .collect(); + for &(pos, _) in poss.iter().rev() { + list.remove(pos); + } + + let conf = conf.clone(); + + spawn(async move { + for (_, name) in poss { + let res = delete_item(&name, &conf).await; + if let Err(err) = res { + eprintln!("{err:?}"); + } + } + }); + }); + + state.on_set_server_address(move |address| { + conf_shared.lock().unwrap().address = address.to_string(); + #[cfg(not(target_arch = "wasm32"))] + let _ = config::save_config(address.to_string()); + }); + + app.run()?; + + Ok(()) +} diff --git a/gui/src/main.rs b/gui/src/main.rs index 73faaae..ceb3186 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -1,198 +1,5 @@ -// Prevent console window in addition to Slint window in Windows release builds when, e.g., starting the app via file manager. Ignored on other platforms. -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -mod config; -use std::{ - rc::Rc, - sync::{Arc, Mutex}, -}; - -use inkopslista_lib::{Item, ItemData}; -use reqwest::Url; -use slint::{Model, ModelRc, ToSharedString, VecModel}; - -use crate::config::{Config, get_config, save_config}; - -slint::include_modules!(); - -// TODO: use anyhow -type MagicAnyError = anyhow::Error; - -/// GET "/api/list" from the server -async fn get_list(conf: &Mutex) -> Result, MagicAnyError> { - let base = Url::parse(&conf.lock().unwrap().address)?; - let url = base.join("/api/list")?; - let list = reqwest::get(url).await?.json::>().await?; - Ok(list) -} - -/// Add or update an item in the list -async fn put_list(item: &str, data: &ItemData, conf: &Mutex) -> Result<(), MagicAnyError> { - let client = reqwest::Client::new(); - let base = Url::parse(&conf.lock().unwrap().address)?.join("/api/list/")?; - let url = base.join(item)?; - let _response = client - .put(url) - .json(data) - .send() - .await? - .error_for_status()?; - Ok(()) -} - -async fn delete_item(item: &str, conf: &Mutex) -> Result<(), MagicAnyError> { - let client = reqwest::Client::new(); - let base = Url::parse(&conf.lock().unwrap().address)?.join("/api/list/")?; - let url = base.join(item)?; - let _response = client.delete(url).send().await?.error_for_status()?; - Ok(()) -} - -/// Convert shopping list to slint types -fn update_slint_list(new_list: Vec, list: &VecModel) { - list.clear(); - new_list - .iter() - .map(|item| SItem { - name: item.name.to_shared_string(), - checked: item.data.checked, - }) - .for_each(|item| list.push(item)); -} - -#[tokio::main] -async fn main() -> Result<(), MagicAnyError> { - let app = App::new()?; - let state = app.global::(); - let list_shared = Rc::new(VecModel::default()); - state.set_list(ModelRc::new(list_shared.clone())); - let conf = get_config() - .await - .inspect_err(|err| eprintln!("{err:?}")) - .unwrap_or_default(); - state.set_server_address(conf.address.to_shared_string()); - let conf_shared = Arc::new(Mutex::new(conf)); - // HACK: load shopping list from server on start - - match get_list(&conf_shared).await { - Ok(new_list) => update_slint_list(new_list, &list_shared), - Err(e) => { - eprintln!("{e:?}"); - } +fn main() { + if let Err(e) = inkopslista::run() { + eprintln!("{e:?}"); } - - let conf = conf_shared.clone(); - let app_weak = app.as_weak(); - state.on_refresh_list(move || { - let conf = conf.clone(); - let app_weak = app_weak.clone(); - tokio::spawn(async move { - let new_list = match get_list(&conf).await { - Ok(new_list) => new_list, - Err(e) => { - eprintln!("{e:?}"); - return; - } - }; - let _ = app_weak.upgrade_in_event_loop(|app| { - let list: ModelRc = app.global::().get_list(); - let list: &VecModel = - list.as_any().downcast_ref().expect("list is a VecModel"); - update_slint_list(new_list, &list); - }); - }); - }); - - let conf = conf_shared.clone(); - let list = list_shared.clone(); - state.on_add_to_list(move |item| { - if list.iter().any(|item2| item2.name == item) { - return; - // TODO: implement increment when number is added - } - list.push(SItem { - checked: false, - name: item.clone(), - }); - let conf = conf.clone(); - tokio::spawn(async move { - // TODO: add number to increment - let res = put_list( - &item, - &ItemData { - checked: false, - amount: 1, - }, - &conf, - ) - .await; - if let Err(err) = res { - eprintln!("{err:?}"); - } - }); - }); - - let conf = conf_shared.clone(); - let list = list_shared.clone(); - state.on_check_item(move |item| { - let positem = list - .iter() - .enumerate() - .find(|(_, item2)| item2.name == item); - let (pos, mut item) = match positem { - Some(positem) => positem, - None => return, - }; - item.checked = !item.checked; - list.set_row_data(pos, item.clone()); - let conf = conf.clone(); - - tokio::spawn(async move { - let res = put_list( - &item.name, - &ItemData { - checked: item.checked, - amount: 1, - }, - &conf, - ) - .await; - if let Err(err) = res { - eprintln!("{err:?}"); - } - }); - }); - - let conf = conf_shared.clone(); - let list = list_shared.clone(); - state.on_delete_checked(move || { - let poss: Vec<_> = list - .iter() - .enumerate() - .filter(|(_, item)| item.checked) - .map(|(pos, item)| (pos, item.name)) - .collect(); - for &(pos, _) in poss.iter().rev() { - list.remove(pos); - } - - let conf = conf.clone(); - - tokio::spawn(async move { - for (_, name) in poss { - let res = delete_item(&name, &conf).await; - if let Err(err) = res { - eprintln!("{err:?}"); - } - } - }); - }); - - state.on_set_server_address(move |address| { - let _ = save_config(address.to_string()); - conf_shared.lock().unwrap().address = address.to_string(); - }); - - app.run()?; - - Ok(()) } diff --git a/gui/src/runtime.rs b/gui/src/runtime.rs new file mode 100644 index 0000000..dddf882 --- /dev/null +++ b/gui/src/runtime.rs @@ -0,0 +1,20 @@ +#[cfg(not(target_arch = "wasm32"))] +use std::sync::LazyLock; + +#[cfg(not(target_arch = "wasm32"))] +pub static RT: LazyLock = LazyLock::new(|| { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("Failed to initialize tokio runtime") +}); + +#[cfg(not(target_arch = "wasm32"))] +pub fn spawn(f: impl Future + Send + 'static) { + RT.spawn(f); +} + +#[cfg(target_arch = "wasm32")] +pub fn spawn(f: impl Future + 'static) { + wasm_bindgen_futures::spawn_local(f); +}