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