Make it run in browser

This commit is contained in:
2025-12-28 23:24:18 +01:00
parent ac82875a3d
commit 258ae03cec
7 changed files with 298 additions and 232 deletions
+13 -2
View File
@@ -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
+11
View File
@@ -0,0 +1,11 @@
<html>
<body>
<!-- canvas required by the Slint runtime -->
<canvas id="canvas"></canvas>
<script type="module">
// import the generated file.
import init from "./pkg/inkopslista.js";
init();
</script>
</body>
</html>
+42 -33
View File
@@ -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<Config, anyhow::Error> {
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<Config, anyhow::Error> {
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)
}
}
+207
View File
@@ -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<Config>) -> Result<Vec<Item>, anyhow::Error> {
let base = Url::parse(&conf.lock().unwrap().address)?;
let url = base.join("/api/list")?;
let list = reqwest::get(url).await?.json::<Vec<Item>>().await?;
Ok(list)
}
/// Add or update an item in the list
async fn put_list(item: &str, data: &ItemData, conf: &Mutex<Config>) -> 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<Config>) -> 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<Item>, list: &VecModel<SItem>) {
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<Mutex<Config>>, app_weak: &slint::Weak<App>) {
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<SItem> = app.global::<State>().get_list();
let list: &VecModel<SItem> = 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::<State>();
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(())
}
+3 -196
View File
@@ -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<Config>) -> Result<Vec<Item>, MagicAnyError> {
let base = Url::parse(&conf.lock().unwrap().address)?;
let url = base.join("/api/list")?;
let list = reqwest::get(url).await?.json::<Vec<Item>>().await?;
Ok(list)
}
/// Add or update an item in the list
async fn put_list(item: &str, data: &ItemData, conf: &Mutex<Config>) -> 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<Config>) -> 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<Item>, list: &VecModel<SItem>) {
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::<State>();
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<SItem> = app.global::<State>().get_list();
let list: &VecModel<SItem> =
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(())
}
+20
View File
@@ -0,0 +1,20 @@
#[cfg(not(target_arch = "wasm32"))]
use std::sync::LazyLock;
#[cfg(not(target_arch = "wasm32"))]
pub static RT: LazyLock<tokio::runtime::Runtime> = 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<Output = ()> + Send + 'static) {
RT.spawn(f);
}
#[cfg(target_arch = "wasm32")]
pub fn spawn(f: impl Future<Output = ()> + 'static) {
wasm_bindgen_futures::spawn_local(f);
}