diff --git a/.changes/store-plugin-rework-js-feat.md b/.changes/store-plugin-rework-js-feat.md new file mode 100644 index 000000000..3306af24f --- /dev/null +++ b/.changes/store-plugin-rework-js-feat.md @@ -0,0 +1,7 @@ +--- +"store-js": minor:feat +--- + +- Add `getStore` +- Add an option to use pre-stored (de)serialize functions (registered on rust) +- Add `LazyStore` diff --git a/.changes/store-plugin-rework.md b/.changes/store-plugin-rework.md new file mode 100644 index 000000000..0d9584afb --- /dev/null +++ b/.changes/store-plugin-rework.md @@ -0,0 +1,23 @@ +--- +"store": minor:breaking +--- + +### Breaking changes: + +- Renamed `StoreCollection` to `StoreState` +- `StoreBuilder::build` now returns a `Result` +- `StoreExt::store` now returns `Result>` + +### Enhancements: + +- Save and cancel pending auto save on drop +- Use absolute path as store's key, fix #984 +- Share store to resource table by default +- Enable auto save with 100ms debounce time by default +- Use pretty json by default, close #1690 + +### New features: + +- Add `get_store` to get shared stores across js and rust side +- Add default (de)serialize functions settings `default_serialize_fn` and `default_deserialize_fn` +- Allow js to use pre-stored (de)serialize functions registered by `register_serialize_fn` and `register_deserialize_fn` diff --git a/examples/api/src-tauri/capabilities/base.json b/examples/api/src-tauri/capabilities/base.json index b76e898cb..607828e93 100644 --- a/examples/api/src-tauri/capabilities/base.json +++ b/examples/api/src-tauri/capabilities/base.json @@ -79,10 +79,6 @@ ], "deny": ["$APPDATA/db/*.stronghold"] }, - "store:allow-entries", - "store:allow-get", - "store:allow-set", - "store:allow-save", - "store:allow-load" + "store:default" ] } diff --git a/examples/api/src-tauri/src/lib.rs b/examples/api/src-tauri/src/lib.rs index f3cacc434..701f67318 100644 --- a/examples/api/src-tauri/src/lib.rs +++ b/examples/api/src-tauri/src/lib.rs @@ -67,7 +67,8 @@ pub fn run() { .user_agent(&format!("Tauri API - {}", std::env::consts::OS)) .title("Tauri API Validation") .inner_size(1000., 800.) - .min_inner_size(600., 400.); + .min_inner_size(600., 400.) + .visible(false); } #[cfg(target_os = "windows")] diff --git a/examples/api/src/views/Store.svelte b/examples/api/src/views/Store.svelte index d8e6653b5..6248b0099 100644 --- a/examples/api/src/views/Store.svelte +++ b/examples/api/src/views/Store.svelte @@ -1,5 +1,5 @@ @@ -44,7 +81,12 @@ - +
+ + + + +
diff --git a/plugins/store/README.md b/plugins/store/README.md index 391d7a0c6..7ab63fd45 100644 --- a/plugins/store/README.md +++ b/plugins/store/README.md @@ -70,7 +70,7 @@ Afterwards all the plugin's APIs are available through the JavaScript guest bind ```typescript import { Store } from '@tauri-apps/plugin-store' -const store = new Store('.settings.dat') +const store = await Store.load('settings.json') await store.set('some-key', { value: 5 }) @@ -81,14 +81,11 @@ if (val) { } else { console.log('val is null') } - -// This manually saves the store. -await store.save() ``` ### Persisting Values -As seen above, values added to the store are not persisted between application loads unless the application is closed gracefully. +Modifications made to the store are automatically saved by default You can manually save a store with: @@ -103,65 +100,43 @@ However, you can also load them manually later like so: await store.load() ``` +### LazyStore + +There's also a high level API `LazyStore` which only loads the store on first access, note that the options will be ignored if a `Store` with that path has already been created + +```typescript +import { LazyStore } from '@tauri-apps/plugin-store' + +const store = new LazyStore('settings.json') +``` + ## Usage from Rust You can also create `Store` instances directly in Rust: ```rust -use tauri_plugin_store::StoreBuilder; +use tauri_plugin_store::StoreExt; use serde_json::json; fn main() { tauri::Builder::default() .plugin(tauri_plugin_store::Builder::default().build()) .setup(|app| { - let mut store = StoreBuilder::new("app_data.bin").build(app.handle().clone()); - - // Attempt to load the store, if it's saved already. - store.load().expect("Failed to load store from disk"); + // This loads the store from disk + let store = app.store("app_data.json")?; // Note that values must be serde_json::Value instances, // otherwise, they will not be compatible with the JavaScript bindings. - store.insert("a".to_string(), json!("b")); - - // You can manually save the store after making changes. - // Otherwise, it will save upon graceful exit as described above. - store.save() + store.set("a".to_string(), json!("b")); }) .run(tauri::generate_context!()) .expect("error while running tauri application"); } ``` -### Loading Gracefully - -If you call `load` on a `Store` that hasn't yet been written to the disk, it will return an error. You must handle this error if you want to gracefully continue and use the default store until you save it to the disk. The example above shows how to do this. - -For example, this would cause a panic if the store has not yet been created: - -```rust -store.load().unwrap(); -``` - -Rather than silently continuing like you may expect. - -You should always handle the error appropriately rather than unwrapping, or you may experience unexpected app crashes: - -```rust -store.load().expect("Failed to load store from disk"); -``` - ### Frontend Interoperability -As you may have noticed, the `Store` crated above isn't accessible to the frontend. To interoperate with stores created by JavaScript use the exported `with_store` method: - -```rust -use tauri::Wry; -use tauri_plugin_store::StoreExt; - -let store = app.store_builder("app_data.bin").build(); -store.insert("key", "value"); -``` +The store created from both Rust side and JavaScript side are stored in the app's resource table and can be accessed by both sides, you can access it by using the same path, with `getStore` and `LazyStore` in the JavaScript side and `get_store` and `store` in the Rust side ## Contributing diff --git a/plugins/store/api-iife.js b/plugins/store/api-iife.js index 77295d7fe..fc04ff00d 100644 --- a/plugins/store/api-iife.js +++ b/plugins/store/api-iife.js @@ -1 +1 @@ -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__})} +if("__TAURI__"in window){var __TAURI_PLUGIN_STORE__=function(t){"use strict";var e,a;function r(t,e=!1){return window.__TAURI_INTERNALS__.transformCallback(t,e)}async function s(t,e={},a){return window.__TAURI_INTERNALS__.invoke(t,e,a)}"function"==typeof SuppressedError&&SuppressedError;class i{get rid(){return function(t,e,a,r){if("a"===a&&!r)throw new TypeError("Private accessor was defined without a getter");if("function"==typeof e?t!==e||!r:!e.has(t))throw new TypeError("Cannot read private member from an object whose class did not declare it");return"m"===a?r:"a"===a?r.call(t):r?r.value:e.get(t)}(this,e,"f")}constructor(t){e.set(this,void 0),function(t,e,a,r,s){if("function"==typeof e?t!==e||!s:!e.has(t))throw new TypeError("Cannot write private member to an object whose class did not declare it");e.set(t,a)}(this,e,t)}async close(){return s("plugin:resources|close",{rid:this.rid})}}async function n(t,e,a){const i={kind:"Any"};return s("plugin:event|listen",{event:t,target:i,handler:r(e)}).then((e=>async()=>async function(t,e){await s("plugin:event|unlisten",{event:t,eventId:e})}(t,e)))}async function o(t,e){return await u.load(t,e)}e=new WeakMap,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"}(a||(a={}));class u extends i{constructor(t){super(t)}static async load(t,e){const a=await s("plugin:store|load",{path:t,...e});return new u(a)}static async get(t){return await s("plugin:store|get_store",{path:t}).then((t=>t?new u(t):null))}async set(t,e){await s("plugin:store|set",{rid:this.rid,key:t,value:e})}async get(t){const[e,a]=await s("plugin:store|get",{rid:this.rid,key:t});return a?e:void 0}async has(t){return await s("plugin:store|has",{rid:this.rid,key:t})}async delete(t){return await s("plugin:store|delete",{rid:this.rid,key:t})}async clear(){await s("plugin:store|clear",{rid:this.rid})}async reset(){await s("plugin:store|reset",{rid:this.rid})}async keys(){return await s("plugin:store|keys",{rid:this.rid})}async values(){return await s("plugin:store|values",{rid:this.rid})}async entries(){return await s("plugin:store|entries",{rid:this.rid})}async length(){return await s("plugin:store|length",{rid:this.rid})}async reload(){await s("plugin:store|reload",{rid:this.rid})}async save(){await s("plugin:store|save",{rid:this.rid})}async onKeyChange(t,e){return await n("store://change",(a=>{a.payload.resourceId===this.rid&&a.payload.key===t&&e(a.payload.exists?a.payload.value:void 0)}))}async onChange(t){return await n("store://change",(e=>{e.payload.resourceId===this.rid&&t(e.payload.key,e.payload.exists?e.payload.value:void 0)}))}}return t.LazyStore=class{get store(){return this._store||(this._store=o(this.path,this.options)),this._store}constructor(t,e){this.path=t,this.options=e}async init(){await this.store}async set(t,e){return(await this.store).set(t,e)}async get(t){return(await this.store).get(t)}async has(t){return(await this.store).has(t)}async delete(t){return(await this.store).delete(t)}async clear(){await(await this.store).clear()}async reset(){await(await this.store).reset()}async keys(){return(await this.store).keys()}async values(){return(await this.store).values()}async entries(){return(await this.store).entries()}async length(){return(await this.store).length()}async reload(){await(await this.store).reload()}async save(){await(await this.store).save()}async onKeyChange(t,e){return(await this.store).onKeyChange(t,e)}async onChange(t){return(await this.store).onChange(t)}async close(){this._store&&await(await this._store).close()}},t.Store=u,t.getStore=async function(t){return await u.get(t)},t.load=o,t}({});Object.defineProperty(window.__TAURI__,"store",{value:__TAURI_PLUGIN_STORE__})} diff --git a/plugins/store/build.rs b/plugins/store/build.rs index 3c9fee015..2e88d59ac 100644 --- a/plugins/store/build.rs +++ b/plugins/store/build.rs @@ -3,7 +3,8 @@ // SPDX-License-Identifier: MIT const COMMANDS: &[&str] = &[ - "create_store", + "load", + "get_store", "set", "get", "has", @@ -12,9 +13,9 @@ const COMMANDS: &[&str] = &[ "reset", "keys", "values", - "length", "entries", - "load", + "length", + "reload", "save", ]; diff --git a/plugins/store/examples/AppSettingsManager/src-tauri/src/main.rs b/plugins/store/examples/AppSettingsManager/src-tauri/src/main.rs index 0dd4e0bc4..f20db4fc2 100644 --- a/plugins/store/examples/AppSettingsManager/src-tauri/src/main.rs +++ b/plugins/store/examples/AppSettingsManager/src-tauri/src/main.rs @@ -5,9 +5,8 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use std::time::Duration; - use serde_json::json; +use tauri::Listener; use tauri_plugin_store::StoreExt; mod app; @@ -18,17 +17,11 @@ fn main() { .plugin(tauri_plugin_store::Builder::new().build()) .setup(|app| { // Init store and load it from disk - 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(); - + let store = app.store("settings.json")?; + app.listen("store://change", |event| { + dbg!(event); + }); let app_settings = AppSettings::load_from_store(&store); - match app_settings { Ok(app_settings) => { let theme = app_settings.theme; diff --git a/plugins/store/examples/AppSettingsManager/src-tauri/tauri.conf.json b/plugins/store/examples/AppSettingsManager/src-tauri/tauri.conf.json index 5a67883d2..d3f60daaf 100644 --- a/plugins/store/examples/AppSettingsManager/src-tauri/tauri.conf.json +++ b/plugins/store/examples/AppSettingsManager/src-tauri/tauri.conf.json @@ -3,6 +3,7 @@ "version": "0.1.0", "identifier": "com.tauri.app-settings-manager", "build": { + "devUrl": "http://localhost:1420", "frontendDist": "../dist" }, "app": { diff --git a/plugins/store/guest-js/index.ts b/plugins/store/guest-js/index.ts index 259b06626..1df89fd52 100644 --- a/plugins/store/guest-js/index.ts +++ b/plugins/store/guest-js/index.ts @@ -8,8 +8,10 @@ import { invoke, Resource } from '@tauri-apps/api/core' interface ChangePayload { path: string + resourceId?: number key: string - value: T | null + value: T + exists: boolean } /** @@ -17,41 +19,213 @@ interface ChangePayload { */ export type StoreOptions = { /** - * Auto save on modification with debounce duration in milliseconds + * Auto save on modification with debounce duration in milliseconds, it's 100ms by default, pass in `false` to disable it */ - autoSave?: boolean + autoSave?: boolean | number + /** + * Name of a serialize function registered in the rust side plugin builder + */ + serializeFnName?: string + /** + * Name of a deserialize function registered in the rust side plugin builder + */ + deserializeFnName?: string + /** + * Force create a new store with default values even if it already exists. + */ + createNew?: boolean +} + +/** + * Create a new Store or load the existing store with the path. + * + * @example + * ```typescript + * import { Store } from '@tauri-apps/api/store'; + * const store = await Store.load('store.json'); + * ``` + * + * @param path Path to save the store in `app_data_dir` + * @param options Store configuration options + */ +export async function load( + path: string, + options?: StoreOptions +): Promise { + return await Store.load(path, options) } /** - * @param path: Path to save the store in `app_data_dir` - * @param options: Store configuration options + * Gets an already loaded store. + * + * If the store is not loaded, returns `null`. In this case you must {@link Store.load load} it. + * + * This function is more useful when you already know the store is loaded + * and just need to access its instance. Prefer {@link Store.load} otherwise. + * + * @example + * ```typescript + * import { getStore } from '@tauri-apps/api/store'; + * const store = await getStore('store.json'); + * ``` + * + * @param path Path of the store. */ -export async function createStore(path: string, options?: StoreOptions) { - const resourceId = await invoke('plugin:store|create_store', { - path, - ...options - }) - return new Store(resourceId, path) +export async function getStore(path: string): Promise { + return await Store.get(path) } /** * A lazy loaded key-value store persisted by the backend layer. */ -export class Store extends Resource { +export class LazyStore implements IStore { + private _store?: Promise + + private get store(): Promise { + if (!this._store) { + this._store = load(this.path, this.options) + } + return this._store + } + + /** + * Note that the options are not applied if someone else already created the store + * @param path Path to save the store in `app_data_dir` + * @param options Store configuration options + */ constructor( - rid: number, - private readonly path: string - ) { + private readonly path: string, + private readonly options?: StoreOptions + ) {} + + /** + * Init/load the store if it's not loaded already + */ + async init(): Promise { + await this.store + } + + async set(key: string, value: unknown): Promise { + return (await this.store).set(key, value) + } + + async get(key: string): Promise { + return (await this.store).get(key) + } + + async has(key: string): Promise { + return (await this.store).has(key) + } + + async delete(key: string): Promise { + return (await this.store).delete(key) + } + + async clear(): Promise { + await (await this.store).clear() + } + + async reset(): Promise { + await (await this.store).reset() + } + + async keys(): Promise { + return (await this.store).keys() + } + + async values(): Promise { + return (await this.store).values() + } + + async entries(): Promise> { + return (await this.store).entries() + } + + async length(): Promise { + return (await this.store).length() + } + + async reload(): Promise { + await (await this.store).reload() + } + + async save(): Promise { + await (await this.store).save() + } + + async onKeyChange( + key: string, + cb: (value: T | undefined) => void + ): Promise { + return (await this.store).onKeyChange(key, cb) + } + + async onChange( + cb: (key: string, value: T | undefined) => void + ): Promise { + return (await this.store).onChange(cb) + } + + async close(): Promise { + if (this._store) { + await (await this._store).close() + } + } +} + +/** + * A key-value store persisted by the backend layer. + */ +export class Store extends Resource implements IStore { + private constructor(rid: number) { super(rid) } /** - * Inserts a key-value pair into the store. + * Create a new Store or load the existing store with the path. * - * @param key - * @param value - * @returns + * @example + * ```typescript + * import { Store } from '@tauri-apps/api/store'; + * const store = await Store.load('store.json'); + * ``` + * + * @param path Path to save the store in `app_data_dir` + * @param options Store configuration options */ + static async load(path: string, options?: StoreOptions): Promise { + const rid = await invoke('plugin:store|load', { + path, + ...options + }) + return new Store(rid) + } + + /** + * Gets an already loaded store. + * + * If the store is not loaded, returns `null`. In this case you must {@link Store.load load} it. + * + * This function is more useful when you already know the store is loaded + * and just need to access its instance. Prefer {@link Store.load} otherwise. + * + * @example + * ```typescript + * import { Store } from '@tauri-apps/api/store'; + * let store = await Store.get('store.json'); + * if (!store) { + * store = await Store.load('store.json'); + * } + * ``` + * + * @param path Path of the store. + */ + static async get(path: string): Promise { + return await invoke('plugin:store|get_store', { path }).then( + (rid) => (rid ? new Store(rid) : null) + ) + } + async set(key: string, value: unknown): Promise { await invoke('plugin:store|set', { rid: this.rid, @@ -60,18 +234,102 @@ export class Store extends Resource { }) } + async get(key: string): Promise { + const [value, exists] = await invoke<[T, boolean]>('plugin:store|get', { + rid: this.rid, + key + }) + return exists ? value : undefined + } + + async has(key: string): Promise { + return await invoke('plugin:store|has', { + rid: this.rid, + key + }) + } + + async delete(key: string): Promise { + return await invoke('plugin:store|delete', { + rid: this.rid, + key + }) + } + + async clear(): Promise { + await invoke('plugin:store|clear', { rid: this.rid }) + } + + async reset(): Promise { + await invoke('plugin:store|reset', { rid: this.rid }) + } + + async keys(): Promise { + return await invoke('plugin:store|keys', { rid: this.rid }) + } + + async values(): Promise { + return await invoke('plugin:store|values', { rid: this.rid }) + } + + async entries(): Promise> { + return await invoke('plugin:store|entries', { rid: this.rid }) + } + + async length(): Promise { + return await invoke('plugin:store|length', { rid: this.rid }) + } + + async reload(): Promise { + await invoke('plugin:store|reload', { rid: this.rid }) + } + + async save(): Promise { + await invoke('plugin:store|save', { rid: this.rid }) + } + + async onKeyChange( + key: string, + cb: (value: T | undefined) => void + ): Promise { + return await listen>('store://change', (event) => { + if (event.payload.resourceId === this.rid && event.payload.key === key) { + cb(event.payload.exists ? event.payload.value : undefined) + } + }) + } + + async onChange( + cb: (key: string, value: T | undefined) => void + ): Promise { + return await listen>('store://change', (event) => { + if (event.payload.resourceId === this.rid) { + cb( + event.payload.key, + event.payload.exists ? event.payload.value : undefined + ) + } + }) + } +} + +interface IStore { /** - * Returns the value for the given `key` or `null` the key does not exist. + * Inserts a key-value pair into the store. * * @param key + * @param value * @returns */ - async get(key: string): Promise { - return await invoke('plugin:store|get', { - rid: this.rid, - key - }) - } + set(key: string, value: unknown): Promise + + /** + * Returns the value for the given `key` or `undefined` if the key does not exist. + * + * @param key + * @returns + */ + get(key: string): Promise /** * Returns `true` if the given `key` exists in the store. @@ -79,12 +337,7 @@ export class Store extends Resource { * @param key * @returns */ - async has(key: string): Promise { - return await invoke('plugin:store|has', { - rid: this.rid, - key - }) - } + has(key: string): Promise /** * Removes a key-value pair from the store. @@ -92,91 +345,67 @@ export class Store extends Resource { * @param key * @returns */ - async delete(key: string): Promise { - return await invoke('plugin:store|delete', { - rid: this.rid, - key - }) - } + delete(key: string): Promise /** * Clears the store, removing all key-value pairs. * - * Note: To clear the storage and reset it to it's `default` value, use `reset` instead. + * Note: To clear the storage and reset it to its `default` value, use {@linkcode reset} instead. * @returns */ - async clear(): Promise { - await invoke('plugin:store|clear', { rid: this.rid }) - } + clear(): Promise /** - * Resets the store to it's `default` value. + * Resets the store to its `default` value. * - * If no default value has been set, this method behaves identical to `clear`. + * If no default value has been set, this method behaves identical to {@linkcode clear}. * @returns */ - async reset(): Promise { - await invoke('plugin:store|reset', { rid: this.rid }) - } + reset(): Promise /** - * Returns a list of all key in the store. + * Returns a list of all keys in the store. * * @returns */ - async keys(): Promise { - return await invoke('plugin:store|keys', { rid: this.rid }) - } + keys(): Promise /** * Returns a list of all values in the store. * * @returns */ - async values(): Promise { - return await invoke('plugin:store|values', { rid: this.rid }) - } + values(): Promise /** * Returns a list of all entries in the store. * * @returns */ - async entries(): Promise> { - return await invoke('plugin:store|entries', { rid: this.rid }) - } + entries(): Promise> /** * Returns the number of key-value pairs in the store. * * @returns */ - async length(): Promise { - return await invoke('plugin:store|length', { rid: this.rid }) - } + length(): Promise /** - * Attempts to load the on-disk state at the stores `path` into memory. + * Attempts to load the on-disk state at the store's `path` into memory. * * This method is useful if the on-disk state was edited by the user and you want to synchronize the changes. * * Note: This method does not emit change events. * @returns */ - async load(): Promise { - await invoke('plugin:store|load', { rid: this.rid }) - } + reload(): Promise /** - * Saves the store to disk at the stores `path`. - * - * As the store is only persisted to disk before the apps exit, changes might be lost in a crash. - * This method lets you persist the store to disk whenever you deem necessary. + * Saves the store to disk at the store's `path`. * @returns */ - async save(): Promise { - await invoke('plugin:store|save', { rid: this.rid }) - } + save(): Promise /** * Listen to changes on a store key. @@ -186,16 +415,10 @@ export class Store extends Resource { * * @since 2.0.0 */ - async onKeyChange( + onKeyChange( key: string, - cb: (value: T | null) => void - ): Promise { - return await listen>('store://change', (event) => { - if (event.payload.path === this.path && event.payload.key === key) { - cb(event.payload.value) - } - }) - } + cb: (value: T | undefined) => void + ): Promise /** * Listen to changes on the store. @@ -204,13 +427,13 @@ export class Store extends Resource { * * @since 2.0.0 */ - async onChange( - cb: (key: string, value: T | null) => void - ): Promise { - return await listen>('store://change', (event) => { - if (event.payload.path === this.path) { - cb(event.payload.key, event.payload.value) - } - }) - } + onChange( + cb: (key: string, value: T | undefined) => void + ): Promise + + /** + * Close the store and cleans up this resource from memory. + * **You should not call any method on this object anymore and should drop any reference to it.** + */ + close(): Promise } diff --git a/plugins/store/permissions/autogenerated/commands/create_store.toml b/plugins/store/permissions/autogenerated/commands/create_store.toml deleted file mode 100644 index cde71c24f..000000000 --- a/plugins/store/permissions/autogenerated/commands/create_store.toml +++ /dev/null @@ -1,13 +0,0 @@ -# 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/commands/get_store.toml b/plugins/store/permissions/autogenerated/commands/get_store.toml new file mode 100644 index 000000000..7c19173a3 --- /dev/null +++ b/plugins/store/permissions/autogenerated/commands/get_store.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-get-store" +description = "Enables the get_store command without any pre-configured scope." +commands.allow = ["get_store"] + +[[permission]] +identifier = "deny-get-store" +description = "Denies the get_store command without any pre-configured scope." +commands.deny = ["get_store"] diff --git a/plugins/store/permissions/autogenerated/commands/reload.toml b/plugins/store/permissions/autogenerated/commands/reload.toml new file mode 100644 index 000000000..92e252531 --- /dev/null +++ b/plugins/store/permissions/autogenerated/commands/reload.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-reload" +description = "Enables the reload command without any pre-configured scope." +commands.allow = ["reload"] + +[[permission]] +identifier = "deny-reload" +description = "Denies the reload command without any pre-configured scope." +commands.deny = ["reload"] diff --git a/plugins/store/permissions/autogenerated/reference.md b/plugins/store/permissions/autogenerated/reference.md index 4e9bf2cc3..5640415da 100644 --- a/plugins/store/permissions/autogenerated/reference.md +++ b/plugins/store/permissions/autogenerated/reference.md @@ -9,19 +9,20 @@ All operations are enabled by default. -- `allow-create-store` -- `allow-clear` -- `allow-delete` -- `allow-entries` +- `allow-load` +- `allow-get-store` +- `allow-set` - `allow-get` - `allow-has` +- `allow-delete` +- `allow-clear` +- `allow-reset` - `allow-keys` +- `allow-values` +- `allow-entries` - `allow-length` -- `allow-load` -- `allow-reset` +- `allow-reload` - `allow-save` -- `allow-set` -- `allow-values` ## Permission Table @@ -61,12 +62,12 @@ Denies the clear command without any pre-configured scope. -`store:allow-create-store` +`store:allow-delete` -Enables the create_store command without any pre-configured scope. +Enables the delete command without any pre-configured scope. @@ -74,12 +75,12 @@ Enables the create_store command without any pre-configured scope. -`store:deny-create-store` +`store:deny-delete` -Denies the create_store command without any pre-configured scope. +Denies the delete command without any pre-configured scope. @@ -87,12 +88,12 @@ Denies the create_store command without any pre-configured scope. -`store:allow-delete` +`store:allow-entries` -Enables the delete command without any pre-configured scope. +Enables the entries command without any pre-configured scope. @@ -100,12 +101,12 @@ Enables the delete command without any pre-configured scope. -`store:deny-delete` +`store:deny-entries` -Denies the delete command without any pre-configured scope. +Denies the entries command without any pre-configured scope. @@ -113,12 +114,12 @@ Denies the delete command without any pre-configured scope. -`store:allow-entries` +`store:allow-get` -Enables the entries command without any pre-configured scope. +Enables the get command without any pre-configured scope. @@ -126,12 +127,12 @@ Enables the entries command without any pre-configured scope. -`store:deny-entries` +`store:deny-get` -Denies the entries command without any pre-configured scope. +Denies the get command without any pre-configured scope. @@ -139,12 +140,12 @@ Denies the entries command without any pre-configured scope. -`store:allow-get` +`store:allow-get-store` -Enables the get command without any pre-configured scope. +Enables the get_store command without any pre-configured scope. @@ -152,12 +153,12 @@ Enables the get command without any pre-configured scope. -`store:deny-get` +`store:deny-get-store` -Denies the get command without any pre-configured scope. +Denies the get_store command without any pre-configured scope. @@ -269,6 +270,32 @@ Denies the load command without any pre-configured scope. +`store:allow-reload` + + + + +Enables the reload command without any pre-configured scope. + + + + + + + +`store:deny-reload` + + + + +Denies the reload command without any pre-configured scope. + + + + + + + `store:allow-reset` diff --git a/plugins/store/permissions/default.toml b/plugins/store/permissions/default.toml index bf888679f..3a3e4b3af 100644 --- a/plugins/store/permissions/default.toml +++ b/plugins/store/permissions/default.toml @@ -11,17 +11,18 @@ All operations are enabled by default. """ permissions = [ - "allow-create-store", - "allow-clear", - "allow-delete", - "allow-entries", + "allow-load", + "allow-get-store", + "allow-set", "allow-get", "allow-has", + "allow-delete", + "allow-clear", + "allow-reset", "allow-keys", + "allow-values", + "allow-entries", "allow-length", - "allow-load", - "allow-reset", + "allow-reload", "allow-save", - "allow-set", - "allow-values", ] diff --git a/plugins/store/permissions/schemas/schema.json b/plugins/store/permissions/schemas/schema.json index 6ebf788ea..4237bc624 100644 --- a/plugins/store/permissions/schemas/schema.json +++ b/plugins/store/permissions/schemas/schema.json @@ -304,16 +304,6 @@ "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", @@ -344,6 +334,16 @@ "type": "string", "const": "deny-get" }, + { + "description": "Enables the get_store command without any pre-configured scope.", + "type": "string", + "const": "allow-get-store" + }, + { + "description": "Denies the get_store command without any pre-configured scope.", + "type": "string", + "const": "deny-get-store" + }, { "description": "Enables the has command without any pre-configured scope.", "type": "string", @@ -384,6 +384,16 @@ "type": "string", "const": "deny-load" }, + { + "description": "Enables the reload command without any pre-configured scope.", + "type": "string", + "const": "allow-reload" + }, + { + "description": "Denies the reload command without any pre-configured scope.", + "type": "string", + "const": "deny-reload" + }, { "description": "Enables the reset command without any pre-configured scope.", "type": "string", diff --git a/plugins/store/src/error.rs b/plugins/store/src/error.rs index afd43addf..ef5ee5938 100644 --- a/plugins/store/src/error.rs +++ b/plugins/store/src/error.rs @@ -3,7 +3,6 @@ // SPDX-License-Identifier: MIT use serde::{Serialize, Serializer}; -use std::path::PathBuf; pub type Result = std::result::Result; @@ -21,9 +20,15 @@ pub enum Error { /// IO error. #[error(transparent)] Io(#[from] std::io::Error), - /// Store not found - #[error("Store \"{0}\" not found")] - NotFound(PathBuf), + // /// Store already exists + // #[error("Store at \"{0}\" already exists")] + // AlreadyExists(PathBuf), + /// Serialize function not found + #[error("Serialize Function \"{0}\" not found")] + SerializeFunctionNotFound(String), + /// Deserialize function not found + #[error("Deserialize Function \"{0}\" not found")] + DeserializeFunctionNotFound(String), /// Some Tauri API failed #[error(transparent)] Tauri(#[from] tauri::Error), diff --git a/plugins/store/src/lib.rs b/plugins/store/src/lib.rs index b05bf4b42..310e80ecd 100644 --- a/plugins/store/src/lib.rs +++ b/plugins/store/src/lib.rs @@ -12,234 +12,392 @@ )] pub use error::{Error, Result}; -use log::warn; -use serde::Serialize; +use serde::{Deserialize, Serialize}; pub use serde_json::Value as JsonValue; use std::{ collections::HashMap, path::{Path, PathBuf}, - sync::{Mutex, Weak}, + sync::{Arc, Mutex}, time::Duration, }; -pub use store::{Store, StoreBuilder, StoreInner}; +pub use store::{resolve_store_path, DeserializeFn, SerializeFn, Store, StoreBuilder}; use tauri::{ plugin::{self, TauriPlugin}, - AppHandle, Manager, ResourceId, RunEvent, Runtime, Webview, + AppHandle, Manager, ResourceId, RunEvent, Runtime, State, }; mod error; mod store; #[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] struct ChangePayload<'a> { path: &'a Path, + resource_id: Option, key: &'a str, - value: &'a JsonValue, + value: Option<&'a JsonValue>, + exists: bool, } -pub struct StoreCollection { - stores: Mutex>>>>, - // frozen: bool, +#[derive(Debug)] +struct StoreState { + stores: Arc>>, + serialize_fns: HashMap, + deserialize_fns: HashMap, + default_serialize: SerializeFn, + default_deserialize: DeserializeFn, } -#[tauri::command] -async fn create_store( +#[derive(Serialize, Deserialize)] +#[serde(untagged)] +enum AutoSave { + DebounceDuration(u64), + Bool(bool), +} + +fn builder( app: AppHandle, - webview: Webview, + store_state: State<'_, StoreState>, path: PathBuf, - auto_save: Option, -) -> Result { + auto_save: Option, + serialize_fn_name: Option, + deserialize_fn_name: Option, + create_new: bool, +) -> Result> { let mut builder = app.store_builder(path); if let Some(auto_save) = auto_save { - builder = builder.auto_save(Duration::from_millis(auto_save)); + match auto_save { + AutoSave::DebounceDuration(duration) => { + builder = builder.auto_save(Duration::from_millis(duration)); + } + AutoSave::Bool(false) => { + builder = builder.disable_auto_save(); + } + _ => {} + } + } + + if let Some(serialize_fn_name) = serialize_fn_name { + let serialize_fn = store_state + .serialize_fns + .get(&serialize_fn_name) + .ok_or_else(|| crate::Error::SerializeFunctionNotFound(serialize_fn_name))?; + builder = builder.serialize(*serialize_fn); + } + + if let Some(deserialize_fn_name) = deserialize_fn_name { + let deserialize_fn = store_state + .deserialize_fns + .get(&deserialize_fn_name) + .ok_or_else(|| crate::Error::DeserializeFunctionNotFound(deserialize_fn_name))?; + builder = builder.deserialize(*deserialize_fn); } - let store = builder.build(); - Ok(webview.resources_table().add(store)) + + if create_new { + builder = builder.create_new(); + } + + Ok(builder) +} + +#[tauri::command] +async fn load( + app: AppHandle, + store_state: State<'_, StoreState>, + path: PathBuf, + auto_save: Option, + serialize_fn_name: Option, + deserialize_fn_name: Option, + create_new: Option, +) -> Result { + let builder = builder( + app, + store_state, + path, + auto_save, + serialize_fn_name, + deserialize_fn_name, + create_new.unwrap_or_default(), + )?; + let (_, rid) = builder.build_inner()?; + Ok(rid) +} + +#[tauri::command] +async fn get_store( + app: AppHandle, + store_state: State<'_, StoreState>, + path: PathBuf, +) -> Result> { + let stores = store_state.stores.lock().unwrap(); + Ok(stores.get(&resolve_store_path(&app, path)?).copied()) } #[tauri::command] async fn set( - webview: Webview, + app: AppHandle, rid: ResourceId, key: String, value: JsonValue, ) -> Result<()> { - let store = webview.resources_table().get::>(rid)?; + let store = app.resources_table().get::>(rid)?; store.set(key, value); Ok(()) } #[tauri::command] async fn get( - webview: Webview, + app: AppHandle, rid: ResourceId, key: String, -) -> Result> { - let store = webview.resources_table().get::>(rid)?; - Ok(store.get(key)) +) -> Result<(Option, bool)> { + let store = app.resources_table().get::>(rid)?; + let value = store.get(key); + let exists = value.is_some(); + Ok((value, exists)) } #[tauri::command] -async fn has(webview: Webview, rid: ResourceId, key: String) -> Result { - let store = webview.resources_table().get::>(rid)?; +async fn has(app: AppHandle, rid: ResourceId, key: String) -> Result { + let store = app.resources_table().get::>(rid)?; Ok(store.has(key)) } #[tauri::command] -async fn delete(webview: Webview, rid: ResourceId, key: String) -> Result { - let store = webview.resources_table().get::>(rid)?; +async fn delete(app: AppHandle, rid: ResourceId, key: String) -> Result { + let store = app.resources_table().get::>(rid)?; Ok(store.delete(key)) } #[tauri::command] -async fn clear(webview: Webview, rid: ResourceId) -> Result<()> { - let store = webview.resources_table().get::>(rid)?; +async fn clear(app: AppHandle, rid: ResourceId) -> Result<()> { + let store = app.resources_table().get::>(rid)?; store.clear(); Ok(()) } #[tauri::command] -async fn reset(webview: Webview, rid: ResourceId) -> Result<()> { - let store = webview.resources_table().get::>(rid)?; +async fn reset(app: AppHandle, rid: ResourceId) -> Result<()> { + let store = app.resources_table().get::>(rid)?; store.reset(); Ok(()) } #[tauri::command] -async fn keys(webview: Webview, rid: ResourceId) -> Result> { - let store = webview.resources_table().get::>(rid)?; +async fn keys(app: AppHandle, rid: ResourceId) -> Result> { + let store = app.resources_table().get::>(rid)?; Ok(store.keys()) } #[tauri::command] -async fn values(webview: Webview, rid: ResourceId) -> Result> { - let store = webview.resources_table().get::>(rid)?; +async fn values(app: AppHandle, rid: ResourceId) -> Result> { + let store = app.resources_table().get::>(rid)?; Ok(store.values()) } #[tauri::command] async fn entries( - webview: Webview, + app: AppHandle, rid: ResourceId, ) -> Result> { - let store = webview.resources_table().get::>(rid)?; + let store = app.resources_table().get::>(rid)?; Ok(store.entries()) } #[tauri::command] -async fn length(webview: Webview, rid: ResourceId) -> Result { - let store = webview.resources_table().get::>(rid)?; +async fn length(app: AppHandle, rid: ResourceId) -> Result { + let store = app.resources_table().get::>(rid)?; Ok(store.length()) } #[tauri::command] -async fn load(webview: Webview, rid: ResourceId) -> Result<()> { - let store = webview.resources_table().get::>(rid)?; - store.load() +async fn reload(app: AppHandle, rid: ResourceId) -> Result<()> { + let store = app.resources_table().get::>(rid)?; + store.reload() } #[tauri::command] -async fn save(webview: Webview, rid: ResourceId) -> Result<()> { - let store = webview.resources_table().get::>(rid)?; +async fn save(app: AppHandle, rid: ResourceId) -> Result<()> { + let store = app.resources_table().get::>(rid)?; store.save() } pub trait StoreExt { - fn store(&self, path: impl AsRef) -> Store; + /// Create a store or load an existing store with default settings at the given path. + /// + /// If the store is already loaded, its instance is automatically returned. + /// + /// # Examples + /// + /// ``` + /// use tauri_plugin_store::StoreExt; + /// + /// tauri::Builder::default() + /// .plugin(tauri_plugin_store::Builder::default().build()) + /// .setup(|app| { + /// let store = app.store("my-store")?; + /// Ok(()) + /// }); + /// ``` + fn store(&self, path: impl AsRef) -> Result>>; + /// Get a store builder. + /// + /// The builder can be used to configure the store. + /// To use the default settings see [`Self::store`]. + /// + /// # Examples + /// + /// ``` + /// use tauri_plugin_store::StoreExt; + /// use std::time::Duration; + /// + /// tauri::Builder::default() + /// .plugin(tauri_plugin_store::Builder::default().build()) + /// .setup(|app| { + /// let store = app.store_builder("users.json").auto_save(Duration::from_secs(1)).build()?; + /// Ok(()) + /// }); + /// ``` fn store_builder(&self, path: impl AsRef) -> StoreBuilder; + /// Get a handle of an already loaded store. + /// + /// If the store is not loaded or does not exist, it returns `None`. + /// + /// Note that using this function can cause race conditions if you fallback to creating or loading the store, + /// so you should consider using [`Self::store`] if you are not sure if the store is loaded or not. + /// + /// # Examples + /// + /// ``` + /// use tauri_plugin_store::StoreExt; + /// + /// tauri::Builder::default() + /// .plugin(tauri_plugin_store::Builder::default().build()) + /// .setup(|app| { + /// let store = if let Some(s) = app.get_store("store.json") { + /// s + /// } else { + /// // this is not thread safe; if another thread is doing the same load/create, + /// // there will be a race condition; in this case we could remove the get_store + /// // and only run app.store() as it will return the existing store if it has been loaded + /// app.store("store.json")? + /// }; + /// Ok(()) + /// }); + /// ``` + fn get_store(&self, path: impl AsRef) -> Option>>; } impl> StoreExt for T { - fn store(&self, path: impl AsRef) -> Store { + fn store(&self, path: impl AsRef) -> Result>> { StoreBuilder::new(self.app_handle(), path).build() } fn store_builder(&self, path: impl AsRef) -> StoreBuilder { StoreBuilder::new(self.app_handle(), path) } + + fn get_store(&self, path: impl AsRef) -> Option>> { + let collection = self.state::(); + let stores = collection.stores.lock().unwrap(); + stores + .get(&resolve_store_path(self.app_handle(), path.as_ref()).ok()?) + .and_then(|rid| self.resources_table().get(*rid).ok()) + } +} + +fn default_serialize( + cache: &HashMap, +) -> std::result::Result, Box> { + Ok(serde_json::to_vec_pretty(&cache)?) +} + +fn default_deserialize( + bytes: &[u8], +) -> std::result::Result, Box> { + serde_json::from_slice(bytes).map_err(Into::into) } -// #[derive(Default)] -pub struct Builder { - stores: HashMap>, - // frozen: bool, +pub struct Builder { + serialize_fns: HashMap, + deserialize_fns: HashMap, + default_serialize: SerializeFn, + default_deserialize: DeserializeFn, } -impl Default for Builder { +impl Default for Builder { fn default() -> Self { Self { - stores: Default::default(), - // frozen: false, + serialize_fns: Default::default(), + deserialize_fns: Default::default(), + default_serialize, + default_deserialize, } } } -impl Builder { +impl Builder { pub fn new() -> Self { 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 - // } + /// Register a serialize function to access it from the JavaScript side + /// + /// # Examples + /// + /// ``` + /// fn no_pretty_json( + /// cache: &std::collections::HashMap, + /// ) -> Result, Box> { + /// Ok(serde_json::to_vec(&cache)?) + /// } + /// + /// tauri::Builder::default() + /// .plugin( + /// tauri_plugin_store::Builder::default() + /// .register_serialize_fn("no-pretty-json".to_owned(), no_pretty_json) + /// .build(), + /// ); + /// ``` + pub fn register_serialize_fn(mut self, name: String, serialize_fn: SerializeFn) -> Self { + self.serialize_fns.insert(name, serialize_fn); + self + } + + /// Register a deserialize function to access it from the JavaScript side + pub fn register_deserialize_fn(mut self, name: String, deserialize_fn: DeserializeFn) -> Self { + self.deserialize_fns.insert(name, deserialize_fn); + self + } + + /// Use this serialize function for stores by default + /// + /// # Examples + /// + /// ``` + /// fn no_pretty_json( + /// cache: &std::collections::HashMap, + /// ) -> Result, Box> { + /// Ok(serde_json::to_vec(&cache)?) + /// } + /// + /// tauri::Builder::default() + /// .plugin( + /// tauri_plugin_store::Builder::default() + /// .default_serialize_fn(no_pretty_json) + /// .build(), + /// ); + /// ``` + pub fn default_serialize_fn(mut self, serialize_fn: SerializeFn) -> Self { + self.default_serialize = serialize_fn; + self + } + + /// Use this deserialize function for stores by default + pub fn default_deserialize_fn(mut self, deserialize_fn: DeserializeFn) -> Self { + self.default_deserialize = deserialize_fn; + self + } /// Builds the plugin. /// @@ -249,56 +407,37 @@ impl Builder { /// tauri::Builder::default() /// .plugin(tauri_plugin_store::Builder::default().build()) /// .setup(|app| { - /// let store = tauri_plugin_store::StoreBuilder::new(app, "store.bin").build(); + /// let store = tauri_plugin_store::StoreBuilder::new(app, "store.bin").build()?; /// Ok(()) /// }); /// ``` - pub fn build(mut self) -> TauriPlugin { + pub fn build(self) -> TauriPlugin { plugin::Builder::new("store") .invoke_handler(tauri::generate_handler![ - create_store, - set, - get, - has, - delete, - clear, - reset, - keys, - values, - length, - entries, - load, - save + load, get_store, set, get, has, delete, clear, reset, keys, values, length, + entries, reload, 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 {path:?} from disk: {err}. Falling back to default values." - ); - } - } - - app_handle.manage(StoreCollection:: { - stores: Mutex::new(HashMap::new()), - // frozen: self.frozen, + app_handle.manage(StoreState { + stores: Arc::new(Mutex::new(HashMap::new())), + serialize_fns: self.serialize_fns, + deserialize_fns: self.deserialize_fns, + default_serialize: self.default_serialize, + default_deserialize: self.default_deserialize, }); - 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_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); - // } - // } + let collection = app_handle.state::(); + let stores = collection.stores.lock().unwrap(); + for (path, rid) in stores.iter() { + if let Ok(store) = app_handle.resources_table().get::>(*rid) { + if let Err(err) = store.save() { + log::error!("failed to save store {path:?} with error {err:?}"); + } + } + } } }) .build() diff --git a/plugins/store/src/store.rs b/plugins/store/src/store.rs index d610525d3..1dc5e1d21 100644 --- a/plugins/store/src/store.rs +++ b/plugins/store/src/store.rs @@ -2,38 +2,32 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -use crate::{ChangePayload, StoreCollection}; +use crate::{ChangePayload, StoreState}; use serde_json::Value as JsonValue; use std::{ collections::HashMap, - fs::{create_dir_all, read, File}, - io::Write, + fs, path::{Path, PathBuf}, sync::{Arc, Mutex}, time::Duration, }; -use tauri::{AppHandle, Emitter, Manager, Resource, Runtime}; +use tauri::{path::BaseDirectory, AppHandle, Emitter, Manager, Resource, ResourceId, Runtime}; use tokio::{ select, sync::mpsc::{unbounded_channel, UnboundedSender}, time::sleep, }; -type SerializeFn = +pub type SerializeFn = fn(&HashMap) -> Result, Box>; -pub(crate) type DeserializeFn = +pub type DeserializeFn = fn(&[u8]) -> Result, Box>; -fn default_serialize( - cache: &HashMap, -) -> Result, Box> { - Ok(serde_json::to_vec(&cache)?) -} - -fn default_deserialize( - bytes: &[u8], -) -> Result, Box> { - serde_json::from_slice(bytes).map_err(Into::into) +pub fn resolve_store_path( + app: &AppHandle, + path: impl AsRef, +) -> crate::Result { + Ok(dunce::simplified(&app.path().resolve(path, BaseDirectory::AppData)?).to_path_buf()) } /// Builds a [`Store`] @@ -41,10 +35,10 @@ pub struct StoreBuilder { app: AppHandle, path: PathBuf, defaults: Option>, - cache: HashMap, - serialize: SerializeFn, - deserialize: DeserializeFn, + serialize_fn: SerializeFn, + deserialize_fn: DeserializeFn, auto_save: Option, + create_new: bool, } impl StoreBuilder { @@ -60,15 +54,18 @@ impl StoreBuilder { /// }); /// ``` pub fn new, P: AsRef>(manager: &M, path: P) -> Self { + let app = manager.app_handle().clone(); + let state = app.state::(); + let serialize_fn = state.default_serialize; + let deserialize_fn = state.default_deserialize; 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(), + app, + path: path.as_ref().to_path_buf(), defaults: None, - cache: Default::default(), - serialize: default_serialize, - deserialize: default_deserialize, - auto_save: None, + serialize_fn, + deserialize_fn, + auto_save: Some(Duration::from_millis(100)), + create_new: false, } } @@ -84,17 +81,16 @@ impl StoreBuilder { /// /// let store = tauri_plugin_store::StoreBuilder::new(app, "store.bin") /// .defaults(defaults) - /// .build(); + /// .build()?; /// Ok(()) /// }); /// ``` pub fn defaults(mut self, defaults: HashMap) -> Self { - self.cache.clone_from(&defaults); self.defaults = Some(defaults); self } - /// Inserts multiple key-value pairs. + /// Inserts multiple default key-value pairs. /// /// # Examples /// ``` @@ -103,14 +99,13 @@ impl StoreBuilder { /// .setup(|app| { /// let store = tauri_plugin_store::StoreBuilder::new(app, "store.bin") /// .default("foo".to_string(), "bar") - /// .build(); + /// .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()) .insert(key, value); @@ -126,12 +121,12 @@ impl StoreBuilder { /// .setup(|app| { /// let store = tauri_plugin_store::StoreBuilder::new(app, "store.json") /// .serialize(|cache| serde_json::to_vec(&cache).map_err(Into::into)) - /// .build(); + /// .build()?; /// Ok(()) /// }); /// ``` pub fn serialize(mut self, serialize: SerializeFn) -> Self { - self.serialize = serialize; + self.serialize_fn = serialize; self } @@ -144,28 +139,25 @@ impl StoreBuilder { /// .setup(|app| { /// let store = tauri_plugin_store::StoreBuilder::new(app, "store.json") /// .deserialize(|bytes| serde_json::from_slice(&bytes).map_err(Into::into)) - /// .build(); + /// .build()?; /// Ok(()) /// }); /// ``` pub fn deserialize(mut self, deserialize: DeserializeFn) -> Self { - self.deserialize = deserialize; + self.deserialize_fn = 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(); + /// .build()?; /// Ok(()) /// }); /// ``` @@ -174,7 +166,64 @@ impl StoreBuilder { self } - /// Builds the [`Store`]. + /// Disable auto save on modified with a debounce duration. + pub fn disable_auto_save(mut self) -> Self { + self.auto_save = None; + self + } + + /// Force create a new store with default values even if it already exists. + pub fn create_new(mut self) -> Self { + self.create_new = true; + self + } + + pub(crate) fn build_inner(mut self) -> crate::Result<(Arc>, ResourceId)> { + let stores = self.app.state::().stores.clone(); + let mut stores = stores.lock().unwrap(); + + self.path = resolve_store_path(&self.app, self.path)?; + + if self.create_new { + if let Some(rid) = stores.remove(&self.path) { + let _ = self.app.resources_table().take::>(rid); + } + } else if let Some(rid) = stores.get(&self.path) { + return Ok((self.app.resources_table().get(*rid).unwrap(), *rid)); + } + + // if stores.contains_key(&self.path) { + // return Err(crate::Error::AlreadyExists(self.path)); + // } + + let mut store_inner = StoreInner::new( + self.app.clone(), + self.path.clone(), + self.defaults.take(), + self.serialize_fn, + self.deserialize_fn, + ); + + if !self.create_new { + let _ = store_inner.load(); + } + + let store = Store { + auto_save: self.auto_save, + auto_save_debounce_sender: Arc::new(Mutex::new(None)), + store: Arc::new(Mutex::new(store_inner)), + }; + + let store = Arc::new(store); + let rid = self.app.resources_table().add_arc(store.clone()); + stores.insert(self.path, rid); + + Ok((store, rid)) + } + + /// Load the existing store with the same path or creates a new [`Store`]. + /// + /// If a store with the same path has already been loaded its instance is returned. /// /// # Examples /// ``` @@ -185,131 +234,116 @@ impl StoreBuilder { /// Ok(()) /// }); /// ``` - 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 { - defaults: self.defaults, - serialize: self.serialize, - deserialize: self.deserialize, - auto_save: self.auto_save, - auto_save_debounce_sender: Arc::new(Mutex::new(None)), - store, - } + pub fn build(self) -> crate::Result>> { + let (store, _) = self.build_inner()?; + Ok(store) } } -pub(crate) enum AutoSaveMessage { +enum AutoSaveMessage { Reset, Cancel, } #[derive(Clone)] -pub struct StoreInner { - pub(crate) app: AppHandle, - pub(crate) path: PathBuf, - pub(crate) cache: HashMap, +struct StoreInner { + app: AppHandle, + path: PathBuf, + cache: HashMap, + defaults: Option>, + serialize_fn: SerializeFn, + deserialize_fn: DeserializeFn, } impl StoreInner { - pub fn new(app: AppHandle, path: PathBuf) -> Self { + fn new( + app: AppHandle, + path: PathBuf, + defaults: Option>, + serialize_fn: SerializeFn, + deserialize_fn: DeserializeFn, + ) -> Self { Self { app, path, - cache: HashMap::new(), + cache: defaults.clone().unwrap_or_default(), + defaults, + serialize_fn, + deserialize_fn, } } - pub fn save(&self, serialize_fn: SerializeFn) -> crate::Result<()> { - let app_dir = self - .app - .path() - .app_data_dir() - .expect("failed to resolve app dir"); - let store_path = app_dir.join(&self.path); - - create_dir_all(store_path.parent().expect("invalid store path"))?; + /// Saves the store to disk at the store's `path`. + pub fn save(&self) -> crate::Result<()> { + fs::create_dir_all(self.path.parent().expect("invalid store path"))?; - let bytes = serialize_fn(&self.cache).map_err(crate::Error::Serialize)?; - let mut f = File::create(&store_path)?; - f.write_all(&bytes)?; + let bytes = (self.serialize_fn)(&self.cache).map_err(crate::Error::Serialize)?; + fs::write(&self.path, bytes)?; Ok(()) } /// Update the store from the on-disk state - pub fn load(&mut self, deserialize_fn: DeserializeFn) -> crate::Result<()> { - let app_dir = self - .app - .path() - .app_data_dir() - .expect("failed to resolve app dir"); - let store_path = app_dir.join(&self.path); - - let bytes = read(store_path)?; + pub fn load(&mut self) -> crate::Result<()> { + let bytes = fs::read(&self.path)?; self.cache - .extend(deserialize_fn(&bytes).map_err(crate::Error::Deserialize)?); + .extend((self.deserialize_fn)(&bytes).map_err(crate::Error::Deserialize)?); Ok(()) } - pub fn insert(&mut self, key: impl Into, value: impl Into) { + /// Inserts a key-value pair into the store. + pub fn set(&mut self, key: impl Into, value: impl Into) { let key = key.into(); let value = value.into(); self.cache.insert(key.clone(), value.clone()); - let _ = self.emit_change_event(&key, &value); + let _ = self.emit_change_event(&key, Some(&value)); } + /// Returns a reference to the value corresponding to the key. pub fn get(&self, key: impl AsRef) -> Option<&JsonValue> { self.cache.get(key.as_ref()) } + /// Returns `true` if the given `key` exists in the store. pub fn has(&self, key: impl AsRef) -> bool { self.cache.contains_key(key.as_ref()) } + /// Removes a key-value pair from the store. pub fn delete(&mut self, key: impl AsRef) -> bool { let flag = self.cache.remove(key.as_ref()).is_some(); if flag { - let _ = self.emit_change_event(key.as_ref(), &JsonValue::Null); + let _ = self.emit_change_event(key.as_ref(), None); } flag } + /// Clears the store, removing all key-value pairs. + /// + /// Note: To clear the storage and reset it to its `default` value, use [`reset`](Self::reset) instead. pub fn clear(&mut self) { let keys: Vec = self.cache.keys().cloned().collect(); self.cache.clear(); for key in &keys { - let _ = self.emit_change_event(key, &JsonValue::Null); + let _ = self.emit_change_event(key, None); } } - pub fn reset(&mut self, defaults: &Option>) { - if let Some(defaults) = &defaults { + /// Resets the store to its `default` value. + /// + /// If no default value has been set, this method behaves identical to [`clear`](Self::clear). + pub fn reset(&mut self) { + if let Some(defaults) = &self.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)); + let _ = self.emit_change_event(key, defaults.get(key)); } } for (key, value) in defaults { if !self.cache.contains_key(key) { - let _ = self.emit_change_event(key, value); + let _ = self.emit_change_event(key, Some(value)); } } self.cache.clone_from(defaults); @@ -318,33 +352,43 @@ impl StoreInner { } } + /// An iterator visiting all keys in arbitrary order. pub fn keys(&self) -> impl Iterator { self.cache.keys() } + /// An iterator visiting all values in arbitrary order. pub fn values(&self) -> impl Iterator { self.cache.values() } + /// An iterator visiting all key-value pairs in arbitrary order. pub fn entries(&self) -> impl Iterator { self.cache.iter() } + /// Returns the number of elements in the store. pub fn len(&self) -> usize { self.cache.len() } + /// Returns true if the store contains no elements. pub fn is_empty(&self) -> bool { self.cache.is_empty() } - fn emit_change_event(&self, key: &str, value: &JsonValue) -> crate::Result<()> { + fn emit_change_event(&self, key: &str, value: Option<&JsonValue>) -> crate::Result<()> { + let state = self.app.state::(); + let stores = state.stores.lock().unwrap(); + let exists = value.is_some(); self.app.emit( "store://change", ChangePayload { path: &self.path, + resource_id: stores.get(&self.path).copied(), key, value, + exists, }, )?; Ok(()) @@ -361,38 +405,45 @@ impl std::fmt::Debug for StoreInner { } 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) +impl Resource for Store { + fn close(self: Arc) { + let store = self.store.lock().unwrap(); + let state = store.app.state::(); + let mut stores = state.stores.lock().unwrap(); + stores.remove(&store.path); } +} +impl Store { + // /// Do something with the inner store, + // /// useful for batching some work if you need higher performance + // pub fn with_store(&self, f: impl FnOnce(&mut StoreInner) -> T) -> T { + // let mut store = self.store.lock().unwrap(); + // f(&mut store) + // } + + /// Inserts a key-value pair into the store. pub fn set(&self, key: impl Into, value: impl Into) { - self.store.lock().unwrap().insert(key.into(), value.into()); + self.store.lock().unwrap().set(key.into(), value.into()); let _ = self.trigger_auto_save(); } + /// Returns the value for the given `key` or `None` if the key does not exist. pub fn get(&self, key: impl AsRef) -> Option { self.store.lock().unwrap().get(key).cloned() } + /// Returns `true` if the given `key` exists in the store. pub fn has(&self, key: impl AsRef) -> bool { self.store.lock().unwrap().has(key) } + /// Removes a key-value pair from the store. pub fn delete(&self, key: impl AsRef) -> bool { let deleted = self.store.lock().unwrap().delete(key); if deleted { @@ -401,24 +452,33 @@ impl Store { deleted } + /// Clears the store, removing all key-value pairs. + /// + /// Note: To clear the storage and reset it to its `default` value, use [`reset`](Self::reset) instead. pub fn clear(&self) { self.store.lock().unwrap().clear(); let _ = self.trigger_auto_save(); } + /// Resets the store to its `default` value. + /// + /// If no default value has been set, this method behaves identical to [`clear`](Self::clear). pub fn reset(&self) { - self.store.lock().unwrap().reset(&self.defaults); + self.store.lock().unwrap().reset(); let _ = self.trigger_auto_save(); } + /// Returns a list of all keys in the store. pub fn keys(&self) -> Vec { self.store.lock().unwrap().keys().cloned().collect() } + /// Returns a list of all values in the store. pub fn values(&self) -> Vec { self.store.lock().unwrap().values().cloned().collect() } + /// Returns a list of all key-value pairs in the store. pub fn entries(&self) -> Vec<(String, JsonValue)> { self.store .lock() @@ -428,19 +488,40 @@ impl Store { .collect() } + /// Returns the number of elements in the store. pub fn length(&self) -> usize { self.store.lock().unwrap().len() } - pub fn load(&self) -> crate::Result<()> { - self.store.lock().unwrap().load(self.deserialize) + /// Returns true if the store contains no elements. + pub fn is_empty(&self) -> bool { + self.store.lock().unwrap().is_empty() + } + + /// Update the store from the on-disk state + pub fn reload(&self) -> crate::Result<()> { + self.store.lock().unwrap().load() } + /// Saves the store to disk at the store's `path`. pub fn save(&self) -> crate::Result<()> { if let Some(sender) = self.auto_save_debounce_sender.lock().unwrap().take() { let _ = sender.send(AutoSaveMessage::Cancel); } - self.store.lock().unwrap().save(self.serialize) + self.store.lock().unwrap().save() + } + + /// Removes the store from the resource table + pub fn close_resource(&self) { + let store = self.store.lock().unwrap(); + let app = store.app.clone(); + let state = app.state::(); + let stores = state.stores.lock().unwrap(); + if let Some(rid) = stores.get(&store.path).copied() { + drop(store); + drop(stores); + let _ = app.resources_table().close(rid); + } } fn trigger_auto_save(&self) -> crate::Result<()> { @@ -459,7 +540,6 @@ impl Store { 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 { @@ -471,7 +551,7 @@ impl Store { } _ = sleep(auto_save_delay) => { auto_save_debounce_sender.lock().unwrap().take(); - let _ = store.lock().unwrap().save(serialize_fn); + let _ = store.lock().unwrap().save(); return; } }; @@ -479,4 +559,18 @@ impl Store { }); Ok(()) } + + fn apply_pending_auto_save(&self) { + // Cancel and save if auto save is pending + if let Some(sender) = self.auto_save_debounce_sender.lock().unwrap().take() { + let _ = sender.send(AutoSaveMessage::Cancel); + let _ = self.save(); + }; + } +} + +impl Drop for Store { + fn drop(&mut self) { + self.apply_pending_auto_save(); + } }