1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
use rexie::{Direction, Error as RexieError, ObjectStore, Rexie, TransactionMode};
use std::rc::Rc;
use thiserror::Error;
use wasm_bindgen::JsValue;
use wasm_bindgen_futures::JsFuture;
#[allow(missing_docs)]
#[derive(Debug, Error)]
pub enum IdbError {
#[error("couldn't build database")]
BuildError {
#[source]
source: RexieError,
},
// The source of this would be a `JsValue`, which we drop for performance
#[error("persistence check failed")]
PersistenceCheckFailed {
/// Whether or not this persistence check could be retried and might
/// result in a success (this is just an estimation).
retry: bool,
},
#[error("an error occurred while constructing an IndexedDB transaction")]
TransactionError {
#[source]
source: RexieError,
},
#[error("an error occurred while trying to set a new value")]
SetError {
#[source]
source: RexieError,
},
#[error("an error occurred while clearing the store of previous values")]
ClearError {
#[source]
source: RexieError,
},
#[error("an error occurred while trying to get the latest value")]
GetError {
#[source]
source: RexieError,
},
}
/// A frozen state store that uses IndexedDB as a backend. This will only store
/// a single frozen state at a time, removing all previously stored states every
/// time a new one is set.
///
/// TODO Browser compatibility information.
#[derive(Clone)]
pub struct IdbFrozenStateStore {
/// A handle to the database.
db: Rc<Rexie>,
}
impl std::fmt::Debug for IdbFrozenStateStore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("IdbFrozenStateStore").finish()
}
}
impl IdbFrozenStateStore {
/// Creates a new store for this origin. If it already exists from a
/// previous visit, the existing one will be interfaced with.
pub async fn new() -> Result<Self, IdbError> {
Self::new_with_name("perseus").await
}
/// Creates a new store for this origin. If it already exists from a
/// previous visit, the existing one will be interfaced with. This also
/// allows the provision of a custom name for the DB.
pub(crate) async fn new_with_name(name: &str) -> Result<Self, IdbError> {
// Build the database
let rexie = Rexie::builder(name)
// IndexedDB uses versions to track database schema changes
// If the structure of this DB ever changes, this MUST be changed, and this should be
// considered a non-API-breaking, but app-breaking change!
.version(1)
.add_object_store(
// We'll store many versions of frozen state so that the user can revert to
// previous states
ObjectStore::new("frozen_state")
// We don't provide a key path because that would lock us in to storing only JS
// objects, and we want to store strings
.auto_increment(true), /* IndexedDB doesn't need us to register value types,
* only things that should be indexed (gotta love JS
* type safety haven't you!) */
)
.build()
.await
.map_err(|err| IdbError::BuildError { source: err })?;
Ok(Self { db: Rc::new(rexie) })
}
/// Gets the stored frozen state. Be warned that the result of this could be
/// arbitrarily old, or it may have been tampered with by the user (in which
/// case Perseus will either return an error that you can handle or
/// it'll fall back to the active state). If no state has been stored yet,
/// this will return `Ok(None)`.
pub async fn get(&self) -> Result<Option<String>, IdbError> {
let transaction = self
.db
.transaction(&["frozen_state"], TransactionMode::ReadOnly)
.map_err(|err| IdbError::TransactionError { source: err })?;
let store = transaction
.store("frozen_state")
.map_err(|err| IdbError::TransactionError { source: err })?;
// Get the last element from the store by working backwards and getting
// everything, with a limit of 1
let frozen_states = store
.get_all(None, Some(1), None, Some(Direction::Prev))
.await
.map_err(|err| IdbError::GetError { source: err })?;
let frozen_state = match frozen_states.get(0) {
Some((_key, value)) => value,
None => return Ok(None),
};
// TODO Do this without cloning the whole thing into the Wasm table and then
// moving it into Rust
let frozen_state = frozen_state.as_string().unwrap();
transaction
.commit()
.await
.map_err(|err| IdbError::TransactionError { source: err })?;
Ok(Some(frozen_state))
}
/// Sets the content to a new frozen state.
pub async fn set(&self, frozen_state: &str) -> Result<(), IdbError> {
let transaction = self
.db
.transaction(&["frozen_state"], TransactionMode::ReadWrite)
.map_err(|err| IdbError::TransactionError { source: err })?;
let store = transaction
.store("frozen_state")
.map_err(|err| IdbError::TransactionError { source: err })?;
// We only store a single frozen state, and they can be quite large, so we'll
// remove any that are already in here
store
.clear()
.await
.map_err(|err| IdbError::ClearError { source: err })?;
// We can add the frozen state directly because it's already a serialized string
// This returns the ID, but we don't need to care about that
store
.add(&JsValue::from(frozen_state), None)
.await
.map_err(|err| IdbError::SetError { source: err })?;
transaction
.commit()
.await
.map_err(|err| IdbError::SetError { source: err })?;
Ok(())
}
/// Clears the stored frozen state entirely and irrecoverably.
pub async fn clear(&self) -> Result<(), IdbError> {
let transaction = self
.db
.transaction(&["frozen_state"], TransactionMode::ReadWrite)
.map_err(|err| IdbError::TransactionError { source: err })?;
let store = transaction
.store("frozen_state")
.map_err(|err| IdbError::TransactionError { source: err })?;
store
.clear()
.await
.map_err(|err| IdbError::ClearError { source: err })?;
Ok(())
}
/// Checks if the storage is persistently stored. If it is, the browser
/// isn't allowed to clear it, the user would have to manually. This doesn't
/// provide a guarantee that all users who've been to your site before
/// will have previous state stored, you should assume that they could well
/// have cleared it manually (or with very stringent privacy settings).
///
/// If this returns an error, a recommendation about whether or not to retry
/// will be attached. You generally shouldn't retry this more than once if
/// there was an error.
///
/// For more information about persistent storage on the web, see [here](https://web.dev/persistent-storage).
pub async fn is_persistent() -> Result<bool, IdbError> {
let storage_manager = web_sys::window().unwrap().navigator().storage();
// If we can't access this, we're probably in a very old browser, so retrying
// isn't worth it in all likelihood
let persisted = storage_manager
.persisted()
.map_err(|_| IdbError::PersistenceCheckFailed { retry: false })?;
let persisted = JsFuture::from(persisted)
.await
.map_err(|_| IdbError::PersistenceCheckFailed { retry: true })?;
let persisted_bool = persisted
.as_bool()
.ok_or(IdbError::PersistenceCheckFailed { retry: true })?;
Ok(persisted_bool)
}
/// Requests persistent storage from the browser. In Firefox, the user will
/// be prompted, though in Chrome the browser will automatically accept or
/// deny based on your site's level of engagement, whether ot not it's
/// been installed or bookmarked, and whether or not it's been granted the
/// permission to show notifications. In other words, do NOT assume that
/// this will be accepted, even if you ask the user very nicely. That
/// said, especially in Firefox, you should display a custom notification
/// before this with `alert()` or similar that explains why
/// your site needs persistent storage for frozen state.
///
/// If this returns `false`, the request was rejected, but you can retry in
/// future (for user experience though, it's recommended to only do so very
/// sparingly). If this returns an error, a recommendation about whether
/// or not to retry will be attached. You generally shouldn't retry this
/// more than once if there was an error.
///
/// For more information about persistent storage on the web, see [here](https://web.dev/persistent-storage).
pub async fn request_persistence() -> Result<bool, IdbError> {
let storage_manager = web_sys::window().unwrap().navigator().storage();
// If we can't access this, we're probably in a very old browser, so retrying
// isn't worth it in all likelihood
let res = storage_manager
.persist()
.map_err(|_| IdbError::PersistenceCheckFailed { retry: false })?;
let res = JsFuture::from(res)
.await
.map_err(|_| IdbError::PersistenceCheckFailed { retry: true })?;
let res_bool = res.as_bool().unwrap();
Ok(res_bool)
}
}