Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Global storage-backed user settings #1443

Closed
esimkowitz opened this issue Sep 8, 2023 · 10 comments
Closed

Global storage-backed user settings #1443

esimkowitz opened this issue Sep 8, 2023 · 10 comments
Labels
ecosystem enhancement New feature or request

Comments

@esimkowitz
Copy link
Contributor

esimkowitz commented Sep 8, 2023

Specific Demand

This is forked from the discussion in #1426. I think that Dioxus would benefit from a global, statically-defined user settings container. Different components would be able to declare a user setting using a hook like use_setting. There would then be another hook that can be used to retrieve all user settings to construct a common preferences page.

Implement Suggestion

Each setting should be tied to a struct that must derive Default to ensure a fallback behavior, as well as a friendly description and name for constructing the preferences page. On browser targets, the window.localStorage offers a good persistent store that will only reset if the browser cache gets purged. On desktop/mobile, this could be exposed as a platform-native collection such as UserDefaults for Swift or it could be written to some file. Ideally, if a user setting gets changed in one window/tab, all other windows/tabs should be re-rendered to reflect the new setting value. The new signals crate would be useful for this.

@ealmloff ealmloff added enhancement New feature or request std Related to the dioxus-std crate labels Sep 9, 2023
@ealmloff
Copy link
Member

ealmloff commented Sep 9, 2023

I think this could be split into two different crates. use_setting, use_cache, etc. with paths from directories and their web equivalents could be a good fit for dioxus-std. The full settings construct could be a different community crate that handles the UI, defaults, and synchronizing between windows/tabs

@esimkowitz
Copy link
Contributor Author

esimkowitz commented Sep 11, 2023

If the backing is something like the ProjectDirs, wouldn't synchronization be required to ensure multiple instances don't conflict when writing back settings?

I agree that constructing the common settings UI and handling system defaults could be handled as a community crate, but synchronization seems like it should be packaged with the hook. I think either the synchronization code should be part of dioxus-std or it could all be done as a community crate.

@ealmloff
Copy link
Member

If the backing is something like the ProjectDirs, wouldn't synchronization be required to ensure multiple instances don't conflict when writing back settings?

You can create an unique id for the app based on something like the package name, author, git hash and version.

I agree that constructing the common settings UI and handling system defaults could be handled as a community crate, but synchronization seems like it should be packaged with the hook. I think either the synchronization code should be part of dioxus-std or it could all be done as a community crate.

Cross application synchronization could fit with dioxus-std. The interprocess crate may be useful to implement that feature

@esimkowitz
Copy link
Contributor Author

Cross application synchronization could fit with dioxus-std.

I've created a new issue to track this: #1456

@ealmloff ealmloff added ecosystem and removed std Related to the dioxus-std crate labels Sep 12, 2023
@esimkowitz
Copy link
Contributor Author

I think for native targets, this can be accomplished without cross-application synchronization by using file locks and watchers. On web and fullstack, operations are synchronized by the browser. We can listen for StorageEvent to update the in-memory setting. The trickiest targets I see are liveview and SSR, I think for now I will leave these as unsupported.

@esimkowitz
Copy link
Contributor Author

esimkowitz commented Sep 22, 2023

Below is my more flushed-out proposal for this feature:

Summary

Use case

Developers should be able to specify a collection of user-facing settings, define a default value for these settings, and subscribe to them wherever relevant. These values should be consistent across all instances of an application for a given target.

Non-goals

I am not concerned with synchronizing values between targets as I am primarily focused on desktop and web targets, where there is no backing server. This may be a beneficial use case for dioxus-fullstack, though, and can be treated as a future consideration.

This is not a design for general state management across instances. I have a separate issue discussing persistent state management for a given instance #1426.

While I propose a concept for a common settings UI, I am not going to define this here. That said, it could follow closely the structure of the router layouts system, using attributes and nesting for each field.

Design

Developer experience

A developer would declare a struct like the following:

#[derive(SettingsCollection)]
struct UserSettings {
    // Optional attributes with the name and description of the settings, for use in a common settings UI.  If using i18n, these can be treated as keys into the i18n dictionary.
    #[name("Appearance")] // Defaults to the name of the field if this is not provided.
    #[description("Select what theme to use for the UI")]
    theme: ThemeSetting,
    #[description("Define how notifications are delivered.")]
    notifications: NotificationSetting,
}

#[derive(Setting, Default)] // A default value will be required for each setting.
enum ThemeSetting {
    Light,
    Dark,
    #[default]
    System,
}

#[derive(Setting)]
struct NotificationsSetting {
    #[name("Enable notifications")]
    enabled: bool,
    #[name("Show previews in notifications")]
    previews: bool,
}

impl Default for NotificationsSetting {
    fn default() -> Self {
        Self { enabled: false, previews: true }
    }
}

In your root node, define the settings as follows:

use_settings::<UserSettings>(cx);

When accessing a setting, a selector will be used. This should return some form of RefCell or Arc to the setting section, allowing the setting to be mutable. Ideally, this should only trigger renders on the setting that is being selected, though I am still thinking of how to enforce this.

let mut theme_settings = use_settings_selector::<UserSettings>(cx, |settings| settings.theme);

Under the hood

I am envisioning three implementations to start. One consideration for all three is whether to store the whole struct as a single entry or break it up and store each field as a separate key. Storing each field as a separate key could offer opportunities to only invalidate some of the entries if the structure of a setting gets changed.

WASM

This is probably the most straightforward. WASM targets have access to the localStorage API, which is a key value store that is synchronized for all sessions under a given origin domain. This is supported by all browsers. There is a window storage event which we can listen for to learn when the localStorage has been updated in another session to update the underlying settings struct.

Desktop

For Desktop targets, I see two approaches, related to the consideration above about storing the whole settings struct as one entry or breaking it up by field. The lightest-weight option is to serialize the settings struct as a JSON/CBOR and dump it into a file in the system's default project directory. Then, we can use file watchers and file locks to synchronize the settings between instances. Another option is to use a key/value DB like redb, which offers built-in consistency between instances, though it doesn't offer a channel for listening for changes.

Mobile

Mobile makes some things easier and others harder. Synchronization is not a concern because there is only ever one instance of an application. That said, we also cannot always access the underlying filesystem to store data, meaning we need to use native APIs.

For iOS, the Swift UserDefaults API is a key/value store similar to localStorage. It has good, well-maintained Rust bindings available, with a new version in beta. It technically also works for macOS, though since macOS can have multiple app instances and UserDefaults does not have a channel for subscribing to changes, it is easier to stick with the above Desktop approach.

It seems like Android may be more flexible for data storage so using an approach like Desktop may be possible, though I have yet to find good Rust bindings for Android to find which directories I am allowed to write to. Same goes for using the Android-equivalents of UserDefaults, SharedPreferences or DataStore. I've found the following API which offers a method to retrieve an internal_data_path for an app, but I haven't evaluated yet how compatible this is with the Dioxus setup.

@ealmloff
Copy link
Member

When accessing a setting, a selector will be used. This should return some form of RefCell or Arc to the setting section, allowing the setting to be mutable. Ideally, this should only trigger renders on the setting that is being selected, though I am still thinking of how to enforce this.

You could use the signals crate with the use_selector hook. It will only rerender the component when the output of the selector changes.

Under the hood
I am envisioning three implementations to start. One consideration for all three is whether to store the whole struct as a single entry or break it up and store each field as a separate key. Storing each field as a separate key could offer opportunities to only invalidate some of the entries if the structure of a setting gets changed.

All of that sounds great! This could be part of the larger use storage crate in dioxus-std. If we make how the data is stored generic so that other platforms can inject there own storage, we can have a user storage handler as an additional implementation for each platform

@ealmloff
Copy link
Member

Some prototyping for the storage abstraction: DioxusLabs/sdk#17

@esimkowitz esimkowitz mentioned this issue Oct 3, 2023
@esimkowitz
Copy link
Contributor Author

I've taken the prototype shared above and added to it the ability to differentiate between storage media that is shared between sessions and those that are ephemeral to the current session. With this, I've added a subscription mechanism for watching for changes to shared storage. ealmloff/dioxus-std#1

@esimkowitz
Copy link
Contributor Author

Closing as this is now addressed in v0.5 :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
ecosystem enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

2 participants