diff --git a/.changes/store-api-refactor.md b/.changes/store-api-refactor.md new file mode 100644 index 000000000..ed049eb1b --- /dev/null +++ b/.changes/store-api-refactor.md @@ -0,0 +1,5 @@ +--- +"store-js": patch +--- + +**Breaking change**: Removed the `Store` constructor and added the `createStore` API. diff --git a/.changes/store-auto-save.md b/.changes/store-auto-save.md new file mode 100644 index 000000000..4babad27c --- /dev/null +++ b/.changes/store-auto-save.md @@ -0,0 +1,7 @@ +--- +"store": patch +--- + +Add a setting `auto_save` to enable a store to debounce save on modification (on calls like set, clear, delete, reset) + +**Breaking change**: Removed the `with_store` API and added `StoreExt::store_builder`. diff --git a/Cargo.lock b/Cargo.lock index 0d4b1212d..8afbabdee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6990,6 +6990,7 @@ dependencies = [ "tauri", "tauri-plugin", "thiserror", + "tokio", ] [[package]] diff --git a/plugins/store/Cargo.toml b/plugins/store/Cargo.toml index 0300b35fe..c2f8ddd91 100644 --- a/plugins/store/Cargo.toml +++ b/plugins/store/Cargo.toml @@ -30,6 +30,10 @@ tauri = { workspace = true } log = { workspace = true } thiserror = { workspace = true } dunce = { workspace = true } +tokio = { version = "1", features = ["sync", "time", "macros"] } [target.'cfg(target_os = "ios")'.dependencies] tauri = { workspace = true, features = ["wry"] } + +[dev-dependencies] +tauri = { workspace = true, features = ["wry"] } diff --git a/plugins/store/README.md b/plugins/store/README.md index be6fdfaee..f7d65a0f7 100644 --- a/plugins/store/README.md +++ b/plugins/store/README.md @@ -149,12 +149,10 @@ As you may have noticed, the `Store` crated above isn't accessible to the fronte ```rust use tauri::Wry; -use tauri_plugin_store::with_store; +use tauri_plugin_store::StoreExt; -let stores = app.state::>(); -let path = PathBuf::from("app_data.bin"); - -with_store(app_handle, stores, path, |store| store.insert("a".to_string(), json!("b"))) +let store = app.store_builder("app_data.bin").build(); +store.insert("key", "value"); ``` ## Contributing diff --git a/plugins/store/api-iife.js b/plugins/store/api-iife.js index 9bd5a3f41..77295d7fe 100644 --- a/plugins/store/api-iife.js +++ b/plugins/store/api-iife.js @@ -1 +1 @@ -if("__TAURI__"in window){var __TAURI_PLUGIN_STORE__=function(t){"use strict";function a(t,a=!1){return window.__TAURI_INTERNALS__.transformCallback(t,a)}async function e(t,a={},e){return window.__TAURI_INTERNALS__.invoke(t,a,e)}var n;async function r(t,n,r){const i={kind:"Any"};return e("plugin:event|listen",{event:t,target:i,handler:a(n)}).then((a=>async()=>async function(t,a){await e("plugin:event|unlisten",{event:t,eventId:a})}(t,a)))}"function"==typeof SuppressedError&&SuppressedError,function(t){t.WINDOW_RESIZED="tauri://resize",t.WINDOW_MOVED="tauri://move",t.WINDOW_CLOSE_REQUESTED="tauri://close-requested",t.WINDOW_DESTROYED="tauri://destroyed",t.WINDOW_FOCUS="tauri://focus",t.WINDOW_BLUR="tauri://blur",t.WINDOW_SCALE_FACTOR_CHANGED="tauri://scale-change",t.WINDOW_THEME_CHANGED="tauri://theme-changed",t.WINDOW_CREATED="tauri://window-created",t.WEBVIEW_CREATED="tauri://webview-created",t.DRAG_ENTER="tauri://drag-enter",t.DRAG_OVER="tauri://drag-over",t.DRAG_DROP="tauri://drag-drop",t.DRAG_LEAVE="tauri://drag-leave"}(n||(n={}));return t.Store=class{constructor(t){this.path=t}async set(t,a){await e("plugin:store|set",{path:this.path,key:t,value:a})}async get(t){return await e("plugin:store|get",{path:this.path,key:t})}async has(t){return await e("plugin:store|has",{path:this.path,key:t})}async delete(t){return await e("plugin:store|delete",{path:this.path,key:t})}async clear(){await e("plugin:store|clear",{path:this.path})}async reset(){await e("plugin:store|reset",{path:this.path})}async keys(){return await e("plugin:store|keys",{path:this.path})}async values(){return await e("plugin:store|values",{path:this.path})}async entries(){return await e("plugin:store|entries",{path:this.path})}async length(){return await e("plugin:store|length",{path:this.path})}async load(){await e("plugin:store|load",{path:this.path})}async save(){await e("plugin:store|save",{path:this.path})}async onKeyChange(t,a){return await r("store://change",(e=>{e.payload.path===this.path&&e.payload.key===t&&a(e.payload.value)}))}async onChange(t){return await r("store://change",(a=>{a.payload.path===this.path&&t(a.payload.key,a.payload.value)}))}},t}({});Object.defineProperty(window.__TAURI__,"store",{value:__TAURI_PLUGIN_STORE__})} +if("__TAURI__"in window){var __TAURI_PLUGIN_STORE__=function(e){"use strict";var t,r;function a(e,t=!1){return window.__TAURI_INTERNALS__.transformCallback(e,t)}async function i(e,t={},r){return window.__TAURI_INTERNALS__.invoke(e,t,r)}"function"==typeof SuppressedError&&SuppressedError;class n{get rid(){return function(e,t,r,a){if("a"===r&&!a)throw new TypeError("Private accessor was defined without a getter");if("function"==typeof t?e!==t||!a:!t.has(e))throw new TypeError("Cannot read private member from an object whose class did not declare it");return"m"===r?a:"a"===r?a.call(e):a?a.value:t.get(e)}(this,t,"f")}constructor(e){t.set(this,void 0),function(e,t,r,a,i){if("function"==typeof t?e!==t||!i:!t.has(e))throw new TypeError("Cannot write private member to an object whose class did not declare it");t.set(e,r)}(this,t,e)}async close(){return i("plugin:resources|close",{rid:this.rid})}}async function s(e,t,r){const n={kind:"Any"};return i("plugin:event|listen",{event:e,target:n,handler:a(t)}).then((t=>async()=>async function(e,t){await i("plugin:event|unlisten",{event:e,eventId:t})}(e,t)))}t=new WeakMap,function(e){e.WINDOW_RESIZED="tauri://resize",e.WINDOW_MOVED="tauri://move",e.WINDOW_CLOSE_REQUESTED="tauri://close-requested",e.WINDOW_DESTROYED="tauri://destroyed",e.WINDOW_FOCUS="tauri://focus",e.WINDOW_BLUR="tauri://blur",e.WINDOW_SCALE_FACTOR_CHANGED="tauri://scale-change",e.WINDOW_THEME_CHANGED="tauri://theme-changed",e.WINDOW_CREATED="tauri://window-created",e.WEBVIEW_CREATED="tauri://webview-created",e.DRAG_ENTER="tauri://drag-enter",e.DRAG_OVER="tauri://drag-over",e.DRAG_DROP="tauri://drag-drop",e.DRAG_LEAVE="tauri://drag-leave"}(r||(r={}));class o extends n{constructor(e,t){super(e),this.path=t}async set(e,t){await i("plugin:store|set",{rid:this.rid,key:e,value:t})}async get(e){return await i("plugin:store|get",{rid:this.rid,key:e})}async has(e){return await i("plugin:store|has",{rid:this.rid,key:e})}async delete(e){return await i("plugin:store|delete",{rid:this.rid,key:e})}async clear(){await i("plugin:store|clear",{rid:this.rid})}async reset(){await i("plugin:store|reset",{rid:this.rid})}async keys(){return await i("plugin:store|keys",{rid:this.rid})}async values(){return await i("plugin:store|values",{rid:this.rid})}async entries(){return await i("plugin:store|entries",{rid:this.rid})}async length(){return await i("plugin:store|length",{rid:this.rid})}async load(){await i("plugin:store|load",{rid:this.rid})}async save(){await i("plugin:store|save",{rid:this.rid})}async onKeyChange(e,t){return await s("store://change",(r=>{r.payload.path===this.path&&r.payload.key===e&&t(r.payload.value)}))}async onChange(e){return await s("store://change",(t=>{t.payload.path===this.path&&e(t.payload.key,t.payload.value)}))}}return e.Store=o,e.createStore=async function(e,t){const r=await i("plugin:store|create_store",{path:e,...t});return new o(r,e)},e}({});Object.defineProperty(window.__TAURI__,"store",{value:__TAURI_PLUGIN_STORE__})} diff --git a/plugins/store/build.rs b/plugins/store/build.rs index 7b54fe421..3c9fee015 100644 --- a/plugins/store/build.rs +++ b/plugins/store/build.rs @@ -3,7 +3,18 @@ // SPDX-License-Identifier: MIT const COMMANDS: &[&str] = &[ - "set", "get", "has", "delete", "clear", "reset", "keys", "values", "length", "entries", "load", + "create_store", + "set", + "get", + "has", + "delete", + "clear", + "reset", + "keys", + "values", + "length", + "entries", + "load", "save", ]; diff --git a/plugins/store/examples/AppSettingsManager/src-tauri/src/app/settings.rs b/plugins/store/examples/AppSettingsManager/src-tauri/src/app/settings.rs index 5fc281b2d..30514a00d 100644 --- a/plugins/store/examples/AppSettingsManager/src-tauri/src/app/settings.rs +++ b/plugins/store/examples/AppSettingsManager/src-tauri/src/app/settings.rs @@ -21,9 +21,8 @@ impl AppSettings { let theme = store .get("appSettings.theme") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .unwrap_or_else(|| "dark".to_string()); + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_else(|| "dark".to_owned()); Ok(AppSettings { launch_at_login, diff --git a/plugins/store/examples/AppSettingsManager/src-tauri/src/main.rs b/plugins/store/examples/AppSettingsManager/src-tauri/src/main.rs index 5926713eb..0dd4e0bc4 100644 --- a/plugins/store/examples/AppSettingsManager/src-tauri/src/main.rs +++ b/plugins/store/examples/AppSettingsManager/src-tauri/src/main.rs @@ -5,17 +5,24 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use tauri_plugin_store::StoreBuilder; +use std::time::Duration; + +use serde_json::json; +use tauri_plugin_store::StoreExt; mod app; use app::settings::AppSettings; fn main() { tauri::Builder::default() - .plugin(tauri_plugin_store::Builder::default().build()) + .plugin(tauri_plugin_store::Builder::new().build()) .setup(|app| { // Init store and load it from disk - let mut store = StoreBuilder::new("settings.json").build(app.handle().clone()); + let store = app + .handle() + .store_builder("settings.json") + .auto_save(Duration::from_millis(100)) + .build(); // If there are no saved settings yet, this will return an error so we ignore the return value. let _ = store.load(); @@ -27,17 +34,20 @@ fn main() { let theme = app_settings.theme; let launch_at_login = app_settings.launch_at_login; - println!("theme {}", theme); - println!("launch_at_login {}", launch_at_login); - - Ok(()) + println!("theme {theme}"); + println!("launch_at_login {launch_at_login}"); + store.set( + "appSettings", + json!({ "theme": theme, "launchAtLogin": launch_at_login }), + ); } Err(err) => { - eprintln!("Error loading settings: {}", err); + eprintln!("Error loading settings: {err}"); // Handle the error case if needed - Err(err) // Convert the error to a Box and return Err(err) here + return Err(err); // Convert the error to a Box and return Err(err) here } } + Ok(()) }) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/plugins/store/guest-js/index.ts b/plugins/store/guest-js/index.ts index dce545ac1..259b06626 100644 --- a/plugins/store/guest-js/index.ts +++ b/plugins/store/guest-js/index.ts @@ -4,7 +4,7 @@ import { listen, type UnlistenFn } from '@tauri-apps/api/event' -import { invoke } from '@tauri-apps/api/core' +import { invoke, Resource } from '@tauri-apps/api/core' interface ChangePayload { path: string @@ -13,12 +13,36 @@ interface ChangePayload { } /** - * A key-value store persisted by the backend layer. + * Options to create a store */ -export class Store { - path: string - constructor(path: string) { - this.path = path +export type StoreOptions = { + /** + * Auto save on modification with debounce duration in milliseconds + */ + autoSave?: boolean +} + +/** + * @param path: Path to save the store in `app_data_dir` + * @param options: Store configuration options + */ +export async function createStore(path: string, options?: StoreOptions) { + const resourceId = await invoke('plugin:store|create_store', { + path, + ...options + }) + return new Store(resourceId, path) +} + +/** + * A lazy loaded key-value store persisted by the backend layer. + */ +export class Store extends Resource { + constructor( + rid: number, + private readonly path: string + ) { + super(rid) } /** @@ -30,7 +54,7 @@ export class Store { */ async set(key: string, value: unknown): Promise { await invoke('plugin:store|set', { - path: this.path, + rid: this.rid, key, value }) @@ -44,7 +68,7 @@ export class Store { */ async get(key: string): Promise { return await invoke('plugin:store|get', { - path: this.path, + rid: this.rid, key }) } @@ -57,7 +81,7 @@ export class Store { */ async has(key: string): Promise { return await invoke('plugin:store|has', { - path: this.path, + rid: this.rid, key }) } @@ -70,7 +94,7 @@ export class Store { */ async delete(key: string): Promise { return await invoke('plugin:store|delete', { - path: this.path, + rid: this.rid, key }) } @@ -82,9 +106,7 @@ export class Store { * @returns */ async clear(): Promise { - await invoke('plugin:store|clear', { - path: this.path - }) + await invoke('plugin:store|clear', { rid: this.rid }) } /** @@ -94,9 +116,7 @@ export class Store { * @returns */ async reset(): Promise { - await invoke('plugin:store|reset', { - path: this.path - }) + await invoke('plugin:store|reset', { rid: this.rid }) } /** @@ -105,9 +125,7 @@ export class Store { * @returns */ async keys(): Promise { - return await invoke('plugin:store|keys', { - path: this.path - }) + return await invoke('plugin:store|keys', { rid: this.rid }) } /** @@ -116,9 +134,7 @@ export class Store { * @returns */ async values(): Promise { - return await invoke('plugin:store|values', { - path: this.path - }) + return await invoke('plugin:store|values', { rid: this.rid }) } /** @@ -127,9 +143,7 @@ export class Store { * @returns */ async entries(): Promise> { - return await invoke('plugin:store|entries', { - path: this.path - }) + return await invoke('plugin:store|entries', { rid: this.rid }) } /** @@ -138,9 +152,7 @@ export class Store { * @returns */ async length(): Promise { - return await invoke('plugin:store|length', { - path: this.path - }) + return await invoke('plugin:store|length', { rid: this.rid }) } /** @@ -152,9 +164,7 @@ export class Store { * @returns */ async load(): Promise { - await invoke('plugin:store|load', { - path: this.path - }) + await invoke('plugin:store|load', { rid: this.rid }) } /** @@ -165,9 +175,7 @@ export class Store { * @returns */ async save(): Promise { - await invoke('plugin:store|save', { - path: this.path - }) + await invoke('plugin:store|save', { rid: this.rid }) } /** diff --git a/plugins/store/permissions/autogenerated/commands/create_store.toml b/plugins/store/permissions/autogenerated/commands/create_store.toml new file mode 100644 index 000000000..cde71c24f --- /dev/null +++ b/plugins/store/permissions/autogenerated/commands/create_store.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-create-store" +description = "Enables the create_store command without any pre-configured scope." +commands.allow = ["create_store"] + +[[permission]] +identifier = "deny-create-store" +description = "Denies the create_store command without any pre-configured scope." +commands.deny = ["create_store"] diff --git a/plugins/store/permissions/autogenerated/reference.md b/plugins/store/permissions/autogenerated/reference.md index 704d759db..4e9bf2cc3 100644 --- a/plugins/store/permissions/autogenerated/reference.md +++ b/plugins/store/permissions/autogenerated/reference.md @@ -9,6 +9,7 @@ All operations are enabled by default. +- `allow-create-store` - `allow-clear` - `allow-delete` - `allow-entries` @@ -60,6 +61,32 @@ Denies the clear command without any pre-configured scope. +`store:allow-create-store` + + + + +Enables the create_store command without any pre-configured scope. + + + + + + + +`store:deny-create-store` + + + + +Denies the create_store command without any pre-configured scope. + + + + + + + `store:allow-delete` diff --git a/plugins/store/permissions/default.toml b/plugins/store/permissions/default.toml index d2369b272..bf888679f 100644 --- a/plugins/store/permissions/default.toml +++ b/plugins/store/permissions/default.toml @@ -11,6 +11,7 @@ All operations are enabled by default. """ permissions = [ + "allow-create-store", "allow-clear", "allow-delete", "allow-entries", diff --git a/plugins/store/permissions/schemas/schema.json b/plugins/store/permissions/schemas/schema.json index 56bced91a..6ebf788ea 100644 --- a/plugins/store/permissions/schemas/schema.json +++ b/plugins/store/permissions/schemas/schema.json @@ -304,6 +304,16 @@ "type": "string", "const": "deny-clear" }, + { + "description": "Enables the create_store command without any pre-configured scope.", + "type": "string", + "const": "allow-create-store" + }, + { + "description": "Denies the create_store command without any pre-configured scope.", + "type": "string", + "const": "deny-create-store" + }, { "description": "Enables the delete command without any pre-configured scope.", "type": "string", diff --git a/plugins/store/src/lib.rs b/plugins/store/src/lib.rs index fa331eb54..b05bf4b42 100644 --- a/plugins/store/src/lib.rs +++ b/plugins/store/src/lib.rs @@ -18,12 +18,13 @@ pub use serde_json::Value as JsonValue; use std::{ collections::HashMap, path::{Path, PathBuf}, - sync::Mutex, + sync::{Mutex, Weak}, + time::Duration, }; -pub use store::{Store, StoreBuilder}; +pub use store::{Store, StoreBuilder, StoreInner}; use tauri::{ plugin::{self, TauriPlugin}, - AppHandle, Manager, RunEvent, Runtime, State, + AppHandle, Manager, ResourceId, RunEvent, Runtime, Webview, }; mod error; @@ -37,177 +38,138 @@ struct ChangePayload<'a> { } pub struct StoreCollection { - stores: Mutex>>, - frozen: bool, + stores: Mutex>>>>, + // frozen: bool, } -pub fn with_store) -> Result>( +#[tauri::command] +async fn create_store( app: AppHandle, - collection: State<'_, StoreCollection>, - path: impl AsRef, - f: F, -) -> Result { - let mut stores = collection.stores.lock().expect("mutex poisoned"); - - let path = path.as_ref(); - if !stores.contains_key(path) { - if collection.frozen { - return Err(Error::NotFound(path.to_path_buf())); - } - - #[allow(unused_mut)] - let mut builder = StoreBuilder::new(path); - - let mut store = builder.build(app); - - // ignore loading errors, just use the default - if let Err(err) = store.load() { - warn!( - "Failed to load store {:?} from disk: {}. Falling back to default values.", - path, err - ); - } - stores.insert(path.to_path_buf(), store); + webview: Webview, + path: PathBuf, + auto_save: Option, +) -> Result { + let mut builder = app.store_builder(path); + if let Some(auto_save) = auto_save { + builder = builder.auto_save(Duration::from_millis(auto_save)); } - - f(stores - .get_mut(path) - .expect("failed to retrieve store. This is a bug!")) + let store = builder.build(); + Ok(webview.resources_table().add(store)) } #[tauri::command] async fn set( - app: AppHandle, - stores: State<'_, StoreCollection>, - path: PathBuf, + webview: Webview, + rid: ResourceId, key: String, value: JsonValue, ) -> Result<()> { - with_store(app, stores, path, |store| store.insert(key, value)) + let store = webview.resources_table().get::>(rid)?; + store.set(key, value); + Ok(()) } #[tauri::command] async fn get( - app: AppHandle, - stores: State<'_, StoreCollection>, - path: PathBuf, + webview: Webview, + rid: ResourceId, key: String, ) -> Result> { - with_store(app, stores, path, |store| Ok(store.get(key).cloned())) + let store = webview.resources_table().get::>(rid)?; + Ok(store.get(key)) } #[tauri::command] -async fn has( - app: AppHandle, - stores: State<'_, StoreCollection>, - path: PathBuf, - key: String, -) -> Result { - with_store(app, stores, path, |store| Ok(store.has(key))) +async fn has(webview: Webview, rid: ResourceId, key: String) -> Result { + let store = webview.resources_table().get::>(rid)?; + Ok(store.has(key)) } #[tauri::command] -async fn delete( - app: AppHandle, - stores: State<'_, StoreCollection>, - path: PathBuf, - key: String, -) -> Result { - with_store(app, stores, path, |store| store.delete(key)) +async fn delete(webview: Webview, rid: ResourceId, key: String) -> Result { + let store = webview.resources_table().get::>(rid)?; + Ok(store.delete(key)) } #[tauri::command] -async fn clear( - app: AppHandle, - stores: State<'_, StoreCollection>, - path: PathBuf, -) -> Result<()> { - with_store(app, stores, path, |store| store.clear()) +async fn clear(webview: Webview, rid: ResourceId) -> Result<()> { + let store = webview.resources_table().get::>(rid)?; + store.clear(); + Ok(()) } #[tauri::command] -async fn reset( - app: AppHandle, - collection: State<'_, StoreCollection>, - path: PathBuf, -) -> Result<()> { - with_store(app, collection, path, |store| store.reset()) +async fn reset(webview: Webview, rid: ResourceId) -> Result<()> { + let store = webview.resources_table().get::>(rid)?; + store.reset(); + Ok(()) } #[tauri::command] -async fn keys( - app: AppHandle, - stores: State<'_, StoreCollection>, - path: PathBuf, -) -> Result> { - with_store(app, stores, path, |store| { - Ok(store.keys().cloned().collect()) - }) +async fn keys(webview: Webview, rid: ResourceId) -> Result> { + let store = webview.resources_table().get::>(rid)?; + Ok(store.keys()) } #[tauri::command] -async fn values( - app: AppHandle, - stores: State<'_, StoreCollection>, - path: PathBuf, -) -> Result> { - with_store(app, stores, path, |store| { - Ok(store.values().cloned().collect()) - }) +async fn values(webview: Webview, rid: ResourceId) -> Result> { + let store = webview.resources_table().get::>(rid)?; + Ok(store.values()) } #[tauri::command] async fn entries( - app: AppHandle, - stores: State<'_, StoreCollection>, - path: PathBuf, + webview: Webview, + rid: ResourceId, ) -> Result> { - with_store(app, stores, path, |store| { - Ok(store - .entries() - .map(|(k, v)| (k.to_owned(), v.to_owned())) - .collect()) - }) + let store = webview.resources_table().get::>(rid)?; + Ok(store.entries()) } #[tauri::command] -async fn length( - app: AppHandle, - stores: State<'_, StoreCollection>, - path: PathBuf, -) -> Result { - with_store(app, stores, path, |store| Ok(store.len())) +async fn length(webview: Webview, rid: ResourceId) -> Result { + let store = webview.resources_table().get::>(rid)?; + Ok(store.length()) } #[tauri::command] -async fn load( - app: AppHandle, - stores: State<'_, StoreCollection>, - path: PathBuf, -) -> Result<()> { - with_store(app, stores, path, |store| store.load()) +async fn load(webview: Webview, rid: ResourceId) -> Result<()> { + let store = webview.resources_table().get::>(rid)?; + store.load() } #[tauri::command] -async fn save( - app: AppHandle, - stores: State<'_, StoreCollection>, - path: PathBuf, -) -> Result<()> { - with_store(app, stores, path, |store| store.save()) +async fn save(webview: Webview, rid: ResourceId) -> Result<()> { + let store = webview.resources_table().get::>(rid)?; + store.save() +} + +pub trait StoreExt { + fn store(&self, path: impl AsRef) -> Store; + fn store_builder(&self, path: impl AsRef) -> StoreBuilder; +} + +impl> StoreExt for T { + fn store(&self, path: impl AsRef) -> Store { + StoreBuilder::new(self.app_handle(), path).build() + } + + fn store_builder(&self, path: impl AsRef) -> StoreBuilder { + StoreBuilder::new(self.app_handle(), path) + } } // #[derive(Default)] pub struct Builder { stores: HashMap>, - frozen: bool, + // frozen: bool, } impl Default for Builder { fn default() -> Self { Self { stores: Default::default(), - frozen: false, + // frozen: false, } } } @@ -217,114 +179,126 @@ impl Builder { Self::default() } - /// Registers a store with the plugin. - /// - /// # Examples - /// - /// ``` - /// use tauri_plugin_store::{StoreBuilder, Builder}; - /// - /// tauri::Builder::default() - /// .setup(|app| { - /// let store = StoreBuilder::new("store.bin").build(app.handle().clone()); - /// let builder = Builder::default().store(store); - /// Ok(()) - /// }); - /// ``` - pub fn store(mut self, store: Store) -> Self { - self.stores.insert(store.path.clone(), store); - self - } - - /// Registers multiple stores with the plugin. - /// - /// # Examples - /// - /// ``` - /// use tauri_plugin_store::{StoreBuilder, Builder}; - /// - /// tauri::Builder::default() - /// .setup(|app| { - /// let store = StoreBuilder::new("store.bin").build(app.handle().clone()); - /// let builder = Builder::default().stores([store]); - /// Ok(()) - /// }); - /// ``` - pub fn stores>>(mut self, stores: T) -> Self { - self.stores = stores - .into_iter() - .map(|store| (store.path.clone(), store)) - .collect(); - self - } - - /// Freezes the collection. - /// - /// This causes requests for plugins that haven't been registered to fail - /// - /// # Examples - /// - /// ``` - /// use tauri_plugin_store::{StoreBuilder, Builder}; - /// - /// tauri::Builder::default() - /// .setup(|app| { - /// let store = StoreBuilder::new("store.bin").build(app.handle().clone()); - /// app.handle().plugin(Builder::default().freeze().build()); - /// Ok(()) - /// }); - /// ``` - pub fn freeze(mut self) -> Self { - self.frozen = true; - self - } + // /// Registers a store with the plugin. + // /// + // /// # Examples + // /// + // /// ``` + // /// use tauri_plugin_store::{StoreBuilder, Builder}; + // /// + // /// tauri::Builder::default() + // /// .setup(|app| { + // /// let store = StoreBuilder::new("store.bin").build(app.handle().clone()); + // /// let builder = Builder::default().store(store); + // /// Ok(()) + // /// }); + // /// ``` + // pub fn store(mut self, store: Store) -> Self { + // self.stores.insert(store.path.clone(), store); + // self + // } + + // /// Registers multiple stores with the plugin. + // /// + // /// # Examples + // /// + // /// ``` + // /// use tauri_plugin_store::{StoreBuilder, Builder}; + // /// + // /// tauri::Builder::default() + // /// .setup(|app| { + // /// let store = StoreBuilder::new("store.bin").build(app.handle().clone()); + // /// let builder = Builder::default().stores([store]); + // /// Ok(()) + // /// }); + // /// ``` + // pub fn stores>>(mut self, stores: T) -> Self { + // self.stores = stores + // .into_iter() + // .map(|store| (store.path.clone(), store)) + // .collect(); + // self + // } + + // /// Freezes the collection. + // /// + // /// This causes requests for plugins that haven't been registered to fail + // /// + // /// # Examples + // /// + // /// ``` + // /// use tauri_plugin_store::{StoreBuilder, Builder}; + // /// + // /// tauri::Builder::default() + // /// .setup(|app| { + // /// let store = StoreBuilder::new("store.bin").build(app.handle().clone()); + // /// app.handle().plugin(Builder::default().freeze().build()); + // /// Ok(()) + // /// }); + // /// ``` + // pub fn freeze(mut self) -> Self { + // self.frozen = true; + // self + // } /// Builds the plugin. /// /// # Examples /// /// ``` - /// use tauri_plugin_store::{StoreBuilder, Builder}; - /// /// tauri::Builder::default() + /// .plugin(tauri_plugin_store::Builder::default().build()) /// .setup(|app| { - /// let store = StoreBuilder::new("store.bin").build(app.handle().clone()); - /// app.handle().plugin(Builder::default().build()); + /// let store = tauri_plugin_store::StoreBuilder::new(app, "store.bin").build(); /// Ok(()) /// }); /// ``` pub fn build(mut self) -> TauriPlugin { plugin::Builder::new("store") .invoke_handler(tauri::generate_handler![ - set, get, has, delete, clear, reset, keys, values, length, entries, load, save + create_store, + set, + get, + has, + delete, + clear, + reset, + keys, + values, + length, + entries, + load, + save ]) .setup(move |app_handle, _api| { for (path, store) in self.stores.iter_mut() { // ignore loading errors, just use the default if let Err(err) = store.load() { warn!( - "Failed to load store {:?} from disk: {}. Falling back to default values.", - path, err - ); + "Failed to load store {path:?} from disk: {err}. Falling back to default values." + ); } } - app_handle.manage(StoreCollection { - stores: Mutex::new(self.stores), - frozen: self.frozen, + app_handle.manage(StoreCollection:: { + stores: Mutex::new(HashMap::new()), + // frozen: self.frozen, }); Ok(()) }) - .on_event(|app_handle, event| { + .on_event(|_app_handle, event| { if let RunEvent::Exit = event { - let collection = app_handle.state::>(); - - for store in collection.stores.lock().expect("mutex poisoned").values() { - if let Err(err) = store.save() { - eprintln!("failed to save store {:?} with error {:?}", store.path, err); - } - } + // let collection = app_handle.state::>(); + + // for store in collection.stores.lock().expect("mutex poisoned").values_mut() { + // if let Some(sender) = store.auto_save_debounce_sender.take() { + // let _ = sender.send(AutoSaveMessage::Cancel); + // } + // if let Err(err) = store.save() { + // eprintln!("failed to save store {:?} with error {:?}", store.path, err); + // } + // } } }) .build() diff --git a/plugins/store/src/store.rs b/plugins/store/src/store.rs index 081844391..b18d00277 100644 --- a/plugins/store/src/store.rs +++ b/plugins/store/src/store.rs @@ -2,19 +2,26 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -use crate::{ChangePayload, Error}; +use crate::{ChangePayload, StoreCollection}; use serde_json::Value as JsonValue; use std::{ collections::HashMap, fs::{create_dir_all, read, File}, io::Write, path::{Path, PathBuf}, + sync::{Arc, Mutex}, + time::Duration, +}; +use tauri::{AppHandle, Emitter, Manager, Resource, Runtime}; +use tokio::{ + select, + sync::mpsc::{unbounded_channel, UnboundedSender}, + time::sleep, }; -use tauri::{AppHandle, Emitter, Manager, Runtime}; type SerializeFn = fn(&HashMap) -> Result, Box>; -type DeserializeFn = +pub(crate) type DeserializeFn = fn(&[u8]) -> Result, Box>; fn default_serialize( @@ -30,35 +37,38 @@ fn default_deserialize( } /// Builds a [`Store`] -pub struct StoreBuilder { +pub struct StoreBuilder { + app: AppHandle, path: PathBuf, defaults: Option>, cache: HashMap, serialize: SerializeFn, deserialize: DeserializeFn, + auto_save: Option, } -impl StoreBuilder { +impl StoreBuilder { /// Creates a new [`StoreBuilder`]. /// /// # Examples /// ``` - /// # fn main() -> Result<(), Box> { - /// use tauri_plugin_store::StoreBuilder; - /// - /// let builder = StoreBuilder::::new("store.bin"); - /// - /// # Ok(()) - /// # } + /// tauri::Builder::default() + /// .plugin(tauri_plugin_store::Builder::default().build()) + /// .setup(|app| { + /// let builder = tauri_plugin_store::StoreBuilder::new(app, "store.bin"); + /// Ok(()) + /// }); /// ``` - pub fn new>(path: P) -> Self { + pub fn new, P: AsRef>(manager: &M, path: P) -> Self { Self { + app: manager.app_handle().clone(), // Since Store.path is only exposed to the user in emit calls we may as well simplify it here already. path: dunce::simplified(path.as_ref()).to_path_buf(), defaults: None, cache: Default::default(), serialize: default_serialize, deserialize: default_deserialize, + auto_save: None, } } @@ -66,19 +76,18 @@ impl StoreBuilder { /// /// # Examples /// ``` - /// # fn main() -> Result<(), Box> { - /// use tauri_plugin_store::StoreBuilder; - /// use std::collections::HashMap; - /// - /// let mut defaults = HashMap::new(); - /// - /// defaults.insert("foo".to_string(), "bar".into()); - /// - /// let builder = StoreBuilder::::new("store.bin") - /// .defaults(defaults); + /// tauri::Builder::default() + /// .plugin(tauri_plugin_store::Builder::default().build()) + /// .setup(|app| { + /// let mut defaults = std::collections::HashMap::new(); + /// defaults.insert("foo".to_string(), "bar".into()); /// - /// # Ok(()) - /// # } + /// let store = tauri_plugin_store::StoreBuilder::new(app, "store.bin") + /// .defaults(defaults) + /// .build(); + /// Ok(()) + /// }); + /// ``` pub fn defaults(mut self, defaults: HashMap) -> Self { self.cache.clone_from(&defaults); self.defaults = Some(defaults); @@ -89,15 +98,18 @@ impl StoreBuilder { /// /// # Examples /// ``` - /// # fn main() -> Result<(), Box> { - /// use tauri_plugin_store::StoreBuilder; - /// - /// let builder = StoreBuilder::::new("store.bin") - /// .default("foo".to_string(), "bar".into()); - /// - /// # Ok(()) - /// # } - pub fn default(mut self, key: String, value: JsonValue) -> Self { + /// tauri::Builder::default() + /// .plugin(tauri_plugin_store::Builder::default().build()) + /// .setup(|app| { + /// let store = tauri_plugin_store::StoreBuilder::new(app, "store.bin") + /// .default("foo".to_string(), "bar") + /// .build(); + /// Ok(()) + /// }); + /// ``` + pub fn default(mut self, key: impl Into, value: impl Into) -> Self { + let key = key.into(); + let value = value.into(); self.cache.insert(key.clone(), value.clone()); self.defaults .get_or_insert(HashMap::new()) @@ -109,14 +121,15 @@ impl StoreBuilder { /// /// # Examples /// ``` - /// # fn main() -> Result<(), Box> { - /// use tauri_plugin_store::StoreBuilder; - /// - /// let builder = StoreBuilder::::new("store.json") - /// .serialize(|cache| serde_json::to_vec(&cache).map_err(Into::into)); - /// - /// # Ok(()) - /// # } + /// tauri::Builder::default() + /// .plugin(tauri_plugin_store::Builder::default().build()) + /// .setup(|app| { + /// let store = tauri_plugin_store::StoreBuilder::new(app, "store.json") + /// .serialize(|cache| serde_json::to_vec(&cache).map_err(Into::into)) + /// .build(); + /// Ok(()) + /// }); + /// ``` pub fn serialize(mut self, serialize: SerializeFn) -> Self { self.serialize = serialize; self @@ -126,53 +139,102 @@ impl StoreBuilder { /// /// # Examples /// ``` - /// # fn main() -> Result<(), Box> { - /// use tauri_plugin_store::StoreBuilder; - /// - /// let builder = StoreBuilder::::new("store.json") - /// .deserialize(|bytes| serde_json::from_slice(&bytes).map_err(Into::into)); - /// - /// # Ok(()) - /// # } + /// tauri::Builder::default() + /// .plugin(tauri_plugin_store::Builder::default().build()) + /// .setup(|app| { + /// let store = tauri_plugin_store::StoreBuilder::new(app, "store.json") + /// .deserialize(|bytes| serde_json::from_slice(&bytes).map_err(Into::into)) + /// .build(); + /// Ok(()) + /// }); + /// ``` pub fn deserialize(mut self, deserialize: DeserializeFn) -> Self { self.deserialize = deserialize; self } + /// Auto save on modified with a debounce duration + /// + /// Note: only works if this store is managed by the plugin (e.g. made using [`crate::with_store`] or inserted into [`crate::Builder`]) + /// + /// # Examples + /// ``` + /// + /// tauri::Builder::default() + /// .plugin(tauri_plugin_store::Builder::default().build()) + /// .setup(|app| { + /// let store = tauri_plugin_store::StoreBuilder::new(app, "store.json") + /// .auto_save(std::time::Duration::from_millis(100)) + /// .build(); + /// Ok(()) + /// }); + /// ``` + pub fn auto_save(mut self, debounce_duration: Duration) -> Self { + self.auto_save = Some(debounce_duration); + self + } + /// Builds the [`Store`]. /// /// # Examples /// ``` /// tauri::Builder::default() + /// .plugin(tauri_plugin_store::Builder::default().build()) /// .setup(|app| { - /// let store = tauri_plugin_store::StoreBuilder::new("store.json").build(app.handle().clone()); + /// let store = tauri_plugin_store::StoreBuilder::new(app, "store.json").build(); /// Ok(()) /// }); /// ``` - pub fn build(self, app: AppHandle) -> Store { + pub fn build(self) -> Store { + let collection = self.app.state::>(); + let mut stores = collection.stores.lock().unwrap(); + let store = stores + .get(&self.path) + .and_then(|store| store.upgrade()) + .unwrap_or_else(|| { + let mut store = StoreInner::new(self.app.clone(), self.path.clone()); + let _ = store.load(self.deserialize); + let store = Arc::new(Mutex::new(store)); + stores.insert( + self.path.clone(), + Arc::>>::downgrade(&store), + ); + store + }); + drop(stores); Store { - app, - path: self.path, defaults: self.defaults, - cache: self.cache, serialize: self.serialize, deserialize: self.deserialize, + auto_save: self.auto_save, + auto_save_debounce_sender: Arc::new(Mutex::new(None)), + store, } } } +pub(crate) enum AutoSaveMessage { + Reset, + Cancel, +} + #[derive(Clone)] -pub struct Store { +pub struct StoreInner { pub(crate) app: AppHandle, pub(crate) path: PathBuf, - defaults: Option>, pub(crate) cache: HashMap, - pub(crate) serialize: SerializeFn, - pub(crate) deserialize: DeserializeFn, } -impl Store { - pub fn save(&self) -> Result<(), Error> { +impl StoreInner { + pub fn new(app: AppHandle, path: PathBuf) -> Self { + Self { + app, + path, + cache: HashMap::new(), + } + } + + pub fn save(&self, serialize_fn: SerializeFn) -> crate::Result<()> { let app_dir = self .app .path() @@ -182,7 +244,7 @@ impl Store { create_dir_all(store_path.parent().expect("invalid store path"))?; - let bytes = (self.serialize)(&self.cache).map_err(Error::Serialize)?; + let bytes = serialize_fn(&self.cache).map_err(crate::Error::Serialize)?; let mut f = File::create(&store_path)?; f.write_all(&bytes)?; @@ -190,7 +252,7 @@ impl Store { } /// Update the store from the on-disk state - pub fn load(&mut self) -> Result<(), Error> { + pub fn load(&mut self, deserialize_fn: DeserializeFn) -> crate::Result<()> { let app_dir = self .app .path() @@ -201,23 +263,16 @@ impl Store { let bytes = read(store_path)?; self.cache - .extend((self.deserialize)(&bytes).map_err(Error::Deserialize)?); + .extend(deserialize_fn(&bytes).map_err(crate::Error::Deserialize)?); Ok(()) } - pub fn insert(&mut self, key: String, value: JsonValue) -> Result<(), Error> { + pub fn insert(&mut self, key: impl Into, value: impl Into) { + let key = key.into(); + let value = value.into(); self.cache.insert(key.clone(), value.clone()); - self.app.emit( - "store://change", - ChangePayload { - path: &self.path, - key: &key, - value: &value, - }, - )?; - - Ok(()) + let _ = self.emit_change_event(&key, &value); } pub fn get(&self, key: impl AsRef) -> Option<&JsonValue> { @@ -228,57 +283,36 @@ impl Store { self.cache.contains_key(key.as_ref()) } - pub fn delete(&mut self, key: impl AsRef) -> Result { + pub fn delete(&mut self, key: impl AsRef) -> bool { let flag = self.cache.remove(key.as_ref()).is_some(); if flag { - self.app.emit( - "store://change", - ChangePayload { - path: &self.path, - key: key.as_ref(), - value: &JsonValue::Null, - }, - )?; + let _ = self.emit_change_event(key.as_ref(), &JsonValue::Null); } - Ok(flag) + flag } - pub fn clear(&mut self) -> Result<(), Error> { + pub fn clear(&mut self) { let keys: Vec = self.cache.keys().cloned().collect(); self.cache.clear(); - for key in keys { - self.app.emit( - "store://change", - ChangePayload { - path: &self.path, - key: &key, - value: &JsonValue::Null, - }, - )?; + for key in &keys { + let _ = self.emit_change_event(key, &JsonValue::Null); } - Ok(()) } - pub fn reset(&mut self) -> Result<(), Error> { - let has_defaults = self.defaults.is_some(); - - if has_defaults { - if let Some(defaults) = &self.defaults { - for (key, value) in &self.cache { - if defaults.get(key) != Some(value) { - let _ = self.app.emit( - "store://change", - ChangePayload { - path: &self.path, - key, - value: defaults.get(key).unwrap_or(&JsonValue::Null), - }, - ); - } + pub fn reset(&mut self, defaults: &Option>) { + if let Some(defaults) = &defaults { + for (key, value) in &self.cache { + if defaults.get(key) != Some(value) { + let _ = + self.emit_change_event(key, defaults.get(key).unwrap_or(&JsonValue::Null)); } - self.cache.clone_from(defaults); } - Ok(()) + for (key, value) in defaults { + if !self.cache.contains_key(key) { + let _ = self.emit_change_event(key, value); + } + } + self.cache.clone_from(defaults); } else { self.clear() } @@ -303,14 +337,152 @@ impl Store { pub fn is_empty(&self) -> bool { self.cache.is_empty() } + + fn emit_change_event(&self, key: &str, value: &JsonValue) -> crate::Result<()> { + self.app.emit( + "store://change", + ChangePayload { + path: &self.path, + key, + value, + }, + )?; + Ok(()) + } } -impl std::fmt::Debug for Store { +impl std::fmt::Debug for StoreInner { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Store") .field("path", &self.path) - .field("defaults", &self.defaults) .field("cache", &self.cache) .finish() } } + +pub struct Store { + defaults: Option>, + serialize: SerializeFn, + deserialize: DeserializeFn, + auto_save: Option, + auto_save_debounce_sender: Arc>>>, + store: Arc>>, +} + +impl Resource for Store {} + +impl Store { + pub fn with_store( + &self, + f: impl FnOnce(&mut StoreInner) -> crate::Result, + ) -> crate::Result { + let mut store = self.store.lock().unwrap(); + f(&mut store) + } + + pub fn set(&self, key: impl Into, value: impl Into) { + self.store.lock().unwrap().insert(key.into(), value.into()); + let _ = self.trigger_auto_save(); + } + + pub fn get(&self, key: impl AsRef) -> Option { + self.store.lock().unwrap().get(key).cloned() + } + + pub fn has(&self, key: impl AsRef) -> bool { + self.store.lock().unwrap().has(key) + } + + pub fn delete(&self, key: impl AsRef) -> bool { + let deleted = self.store.lock().unwrap().delete(key); + if deleted { + let _ = self.trigger_auto_save(); + } + deleted + } + + pub fn clear(&self) { + self.store.lock().unwrap().clear(); + let _ = self.trigger_auto_save(); + } + + pub fn reset(&self) { + self.store.lock().unwrap().reset(&self.defaults); + let _ = self.trigger_auto_save(); + } + + pub fn keys(&self) -> Vec { + self.store.lock().unwrap().keys().cloned().collect() + } + + pub fn values(&self) -> Vec { + self.store.lock().unwrap().values().cloned().collect() + } + + pub fn entries(&self) -> Vec<(String, JsonValue)> { + self.store + .lock() + .unwrap() + .entries() + .map(|(k, v)| (k.to_owned(), v.to_owned())) + .collect() + } + + pub fn length(&self) -> usize { + self.store.lock().unwrap().len() + } + + pub fn load(&self) -> crate::Result<()> { + self.store.lock().unwrap().load(self.deserialize) + } + + pub fn save(&self) -> crate::Result<()> { + self.store.lock().unwrap().save(self.serialize) + } + + fn trigger_auto_save(&self) -> crate::Result<()> { + let Some(auto_save_delay) = self.auto_save else { + return Ok(()); + }; + if auto_save_delay.is_zero() { + return self.save(); + } + let mut auto_save_debounce_sender = self.auto_save_debounce_sender.lock().unwrap(); + if let Some(ref sender) = *auto_save_debounce_sender { + let _ = sender.send(AutoSaveMessage::Reset); + return Ok(()); + } + let (sender, mut receiver) = unbounded_channel(); + auto_save_debounce_sender.replace(sender); + drop(auto_save_debounce_sender); + let store = self.store.clone(); + let serialize_fn = self.serialize; + let auto_save_debounce_sender = self.auto_save_debounce_sender.clone(); + tauri::async_runtime::spawn(async move { + loop { + select! { + should_cancel = receiver.recv() => { + if matches!(should_cancel, Some(AutoSaveMessage::Cancel) | None) { + return; + } + } + _ = sleep(auto_save_delay) => { + let _ = store.lock().unwrap().save(serialize_fn); + auto_save_debounce_sender.lock().unwrap().take(); + return; + } + }; + } + }); + Ok(()) + } +} + +impl Drop for Store { + fn drop(&mut self) { + let auto_save_debounce_sender = self.auto_save_debounce_sender.lock().unwrap(); + if let Some(ref sender) = *auto_save_debounce_sender { + let _ = sender.send(AutoSaveMessage::Cancel); + } + } +}