Commit 62fe32759529
tarball11 files changed+119-33
@@ -0,0 +1,2 @@
1
+[target.wasm32-unknown-unknown]2
+rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""]@@ -1150,9 +1150,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
11501150
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"11511151
dependencies = [11521152
"cfg-if",1153
+ "js-sys",11531154
"libc",11541155
"r-efi 5.3.0",11551156
"wasip2",1157
+ "wasm-bindgen",11561158
]11571159
11581160
[[package]]@@ -1821,12 +1823,16 @@ name = "macltok"
18211823
version = "0.1.0"18221824
dependencies = [18231825
"futures-util",1826
+ "getrandom 0.2.17",1827
+ "getrandom 0.3.4",1828
+ "getrandom 0.4.3",18241829
"http",18251830
"matrix-sdk",18261831
"matrix-sdk-crypto",18271832
"matrix-sdk-test",18281833
"matrix-sdk-ui",18291834
"mime",1835
+ "reqwest",18301836
"ruma",18311837
"serde",18321838
"serde_json",@@ -1835,6 +1841,7 @@ dependencies = [
18351841
"tokio",18361842
"tracing",18371843
"url",1844
+ "uuid",18381845
"wiremock",18391846
]18401847
@@ -2154,6 +2161,7 @@ dependencies = [
21542161
"futures-util",21552162
"getrandom 0.4.3",21562163
"gloo-utils",2164
+ "growable-bloom-filter",21572165
"hkdf",21582166
"js-sys",21592167
"matrix-sdk-base",@@ -9,6 +9,7 @@ default = []
99
serde = ["dep:serde"]1010
sqlite = ["matrix-sdk/sqlite", "serde", "dep:url"]1111
bundled-sqlite = ["sqlite", "matrix-sdk/bundled-sqlite"]12
+indexeddb = ["matrix-sdk/indexeddb", "serde", "dep:url"]1213
1314
[dependencies]1415
futures-util = { version = "0.3.31", default-features = false, features = ["std"] }@@ -25,6 +26,16 @@ tokio = { version = "1.47.1", features = ["macros", "sync"] }
2526
tracing = "0.1.41"2627
url = { version = "2.5.7", optional = true }2728
29
+[target.'cfg(target_family = "wasm")'.dependencies]30
+getrandom = { version = "0.2", features = ["js"] }31
+getrandom3 = { package = "getrandom", version = "0.3", features = ["wasm_js"] }32
+getrandom4 = { package = "getrandom", version = "0.4", features = ["wasm_js"] }33
+matrix-sdk = { version = "0.18.0", default-features = false, features = ["js"] }34
+matrix-sdk-ui = { version = "0.18.0", default-features = false, features = ["js"] }35
+# the sdk's own wasm reqwest leaves out stream, which the bounded media download needs36
+reqwest = { version = "0.13.1", default-features = false, features = ["stream"] }37
+uuid = { version = "1", features = ["js"] }38
+2839
[dev-dependencies]2940
matrix-sdk = { version = "0.18.0", default-features = false, features = ["testing"] }3041
matrix-sdk-test = "0.18.0"@@ -13,7 +13,8 @@ The crate currently provides
1313
- text, emote, reply, edit, redaction, reaction, receipt and typing operations1414
- raw encrypted or unencrypted media acquisition with configurable byte limits1515
- encryption state, recovery-key import and SAS verification16
-- password login and session restoration with caller-owned encrypted SQLite stores16
+- password login and session restoration with caller-owned encrypted SQLite or17
+ IndexedDB stores1718
1819
Matrix room, event, user, device and transaction identifiers remain strongly1920
typed. A toolkit timeline identity covers local echoes without pretending they@@ -32,10 +33,10 @@ start a timeline from `TimelineReset`, then apply `TimelineChanged` diffs with
3233
`TimelineDiff::try_apply`. when one doesn't fit, stop applying, call `resync`,3334
and pick up from the next `TimelineReset`3435
35
-With `sqlite`, persist the initial `Session::record()` separately from its36
-encrypted SQLite store. On `SessionTokensChanged`, obtain a new record from36
+With `sqlite` or `indexeddb`, persist the initial `Session::record()`37
+separately from its encrypted store. On `SessionTokensChanged`, obtain a new record from3738
`ClientHandle::session_record()` and replace the saved record atomically in38
-protected storage. Restore with the same store path and passphrase.39
+protected storage. Restore with the same store location and passphrase.3940
4041
`LoggedOut` and `SessionAccessRejected` both stop the engine. after a logout or a4142
hard rejection the record and the store are dead, bin them. a soft logout wants@@ -45,11 +46,39 @@ store directory per login, the sdk won't share a crypto store between devices.
4546
The headless example is4647
login-only; it does not persist sessions across runs.4748
49
+## wasm50
+51
+macltok builds for `wasm32-unknown-unknown`. the `indexeddb` feature gives you52
+`StoreConfig::indexeddb` in place of `StoreConfig::sqlite`, and asking for either53
+on the wrong target won't compile. `Engine::run` hands back a `LocalBoxFuture`54
+there, so spawn it with `wasm_bindgen_futures::spawn_local`55
+56
+getrandom picks its browser backend from a cfg flag, and `.cargo/config.toml`57
+only reaches this crate's own builds. every cdylib that pulls macltok in needs58
+it too59
+60
+```toml61
+[target.wasm32-unknown-unknown]62
+rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""]63
+```64
+65
+what's different in a browser66
+67
+- a panic aborts the whole instance. natively a panicking command costs its68
+ caller an `Interrupted`, here it takes the engine and everything else with it69
+- `MediaUploadProgress` only ever reports completion, the sdk's wasm http path70
+ has no upload progress71
+- the sdk doesn't retry requests or time them out individually. sync still72
+ restarts itself with backoff, that part is ours73
+- two tabs or workers on one store each need `StoreConfig::with_lock_holder`74
+ with a name of their own, or they both think they hold the crypto store75
+4876
## Dependency policy4977
5078
UI frameworks, image decoders, application-directory discovery and logging5179
subscribers do not belong in this crate. SQLite remains optional through the52
-`sqlite` and `bundled-sqlite` features. Media decoding belongs to the consumer.80
+`sqlite` and `bundled-sqlite` features, IndexedDB through `indexeddb`. Media81
+decoding belongs to the consumer.5382
5483
## Verification5584
@@ -30,7 +30,8 @@ doing the work died before it could answer. `challenge` turns up with
3030
the run loop routes and does nothing slow. every task it spawns is registered3131
with a context. a command task that panics costs its caller an `Interrupted`3232
and gets logged. a watcher or a room actor that panics takes the engine down,33
-emitting `Failed` then `Stopped`33
+emitting `Failed` then `Stopped`. natively, anyway. wasm aborts the whole34
+instance on any panic and nothing is left to log it3435
3536
sync restarts itself with backoff after an error or an unexpected stop, and3637
`ConnectionChanged` shows `Offline` meanwhile. a run that drops again inside@@ -141,8 +142,8 @@ watcher there reports them
141142
## sessions and secrets142143
143144
`Session::login_password`, `begin_oauth` and `restore` share one client builder144
-with encrypted sqlite, refresh tokens and threads. secret-bearing inputs don't145
-derive `Clone`145
+with an encrypted store, refresh tokens and threads. the store is sqlite146
+natively and indexeddb in a browser. secret-bearing inputs don't derive `Clone`146147
147148
the caller owns the saved record. save `Session::record()` after login and148149
replace it atomically with `ClientHandle::session_record()` on every@@ -9,7 +9,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
99
let passphrase = required("MATRIX_STORE_PASSPHRASE")?;1010
// a real client keeps this somewhere safer, it holds the access token1111
let record_path = store_path.join("session.json");12
- let store = StoreConfig::new(&store_path, &passphrase);12
+ let store = StoreConfig::sqlite(&store_path, &passphrase);1313
let session = match std::fs::read(&record_path) {1414
Ok(saved) => Session::restore(serde_json::from_slice(&saved)?, store).await?,1515
Err(_) => {@@ -1,10 +1,9 @@
1
-use std::{2
- collections::HashMap,3
- path::{Path, PathBuf},4
-};1
+use std::collections::HashMap;2
+#[cfg(feature = "sqlite")]3
+use std::path::PathBuf;54
65
use matrix_sdk::{7
- Client, ClientBuilder, ThreadingSupport,6
+ Client, ClientBuilder, SendOutsideWasm, SyncOutsideWasm, ThreadingSupport,87
authentication::{98
AuthSession,109
matrix::MatrixSession,@@ -13,6 +12,7 @@ use matrix_sdk::{
1312
registration::{ApplicationType, ClientMetadata, Localized, OAuthGrantType},1413
},1514
},15
+ cross_process_lock::CrossProcessLockConfig,1616
encryption::{BackupDownloadStrategy, EncryptionSettings},1717
ruma::{OwnedDeviceId, serde::Raw},1818
};@@ -20,7 +20,7 @@ use serde::{Deserialize, Serialize};
2020
use thiserror::Error;2121
use url::Url;2222
23
-use crate::{ClientHandle, Engine, EngineConfig, EngineError, EventStream};23
+use crate::{ClientHandle, Engine, EngineConfig, EngineError, EventStream, error::SourceError};2424
2525
pub struct Session {2626
client: Client,@@ -173,27 +173,49 @@ impl PendingOAuthLogin {
173173
}174174
175175
pub struct StoreConfig {176
- path: PathBuf,176
+ location: StoreLocation,177177
passphrase: String,178
+ lock_holder: Option<String>,179
+}180
+181
+enum StoreLocation {182
+ #[cfg(feature = "sqlite")]183
+ Sqlite(PathBuf),184
+ #[cfg(feature = "indexeddb")]185
+ IndexedDb(String),178186
}179187
180188
impl StoreConfig {189
+ #[cfg(feature = "sqlite")]190
+ #[must_use]191
+ pub fn sqlite(path: impl Into<PathBuf>, passphrase: impl Into<String>) -> Self {192
+ Self {193
+ location: StoreLocation::Sqlite(path.into()),194
+ passphrase: passphrase.into(),195
+ lock_holder: None,196
+ }197
+ }198
+199
+ #[cfg(feature = "indexeddb")]181200
#[must_use]182
- pub fn new(path: impl Into<PathBuf>, passphrase: impl Into<String>) -> Self {201
+ pub fn indexeddb(name: impl Into<String>, passphrase: impl Into<String>) -> Self {183202
Self {184
- path: path.into(),203
+ location: StoreLocation::IndexedDb(name.into()),185204
passphrase: passphrase.into(),205
+ lock_holder: None,186206
}187207
}188208
209
+ /// the sdk names every client `main`, so two tabs on one store would both think they hold its lock189210
#[must_use]190
- pub fn path(&self) -> &Path {191
- &self.path211
+ pub fn with_lock_holder(mut self, holder: impl Into<String>) -> Self {212
+ self.lock_holder = Some(holder.into());213
+ self192214
}193215
}194216
195217
/// Serializable authentication secrets. Store this separately from the196
-/// encrypted SQLite store in protected, preferably encrypted storage.218
+/// encrypted store in protected, preferably encrypted storage.197219
/// A saved record must be replaced when `Event::SessionTokensChanged` arrives.198220
#[derive(Clone, Deserialize, Serialize)]199221
pub struct SessionRecord {@@ -224,7 +246,7 @@ pub enum AccountError {
224246
Operation {225247
context: &'static str,226248
#[source]227
- source: Box<dyn std::error::Error + Send + Sync>,249
+ source: Box<SourceError>,228250
},229251
#[error("the homeserver returned no authenticated session")]230252
MissingSession,@@ -377,18 +399,26 @@ fn encryption_settings() -> EncryptionSettings {
377399
}378400
379401
fn matrix_client_builder(store: &StoreConfig) -> ClientBuilder {380
- Client::builder()402
+ let mut builder = Client::builder()381403
.with_encryption_settings(encryption_settings())382404
.with_threading_support(ThreadingSupport::Enabled {383405
with_subscriptions: false,384406
})385
- .handle_refresh_tokens()386
- .sqlite_store(&store.path, Some(&store.passphrase))407
+ .handle_refresh_tokens();408
+ if let Some(holder) = &store.lock_holder {409
+ builder = builder.cross_process_store_config(CrossProcessLockConfig::multi_process(holder));410
+ }411
+ match &store.location {412
+ #[cfg(feature = "sqlite")]413
+ StoreLocation::Sqlite(path) => builder.sqlite_store(path, Some(&store.passphrase)),414
+ #[cfg(feature = "indexeddb")]415
+ StoreLocation::IndexedDb(name) => builder.indexeddb_store(name, Some(&store.passphrase)),416
+ }387417
}388418
389419
fn account_error(390420
context: &'static str,391
- source: impl std::error::Error + Send + Sync + 'static,421
+ source: impl std::error::Error + SendOutsideWasm + SyncOutsideWasm + 'static,392422
) -> AccountError {393423
AccountError::Operation {394424
context,@@ -19,9 +19,9 @@ use crate::InteractiveAuthChallenge;
1919
2020
/// the sdk's `SendOutsideWasm` isn't an auto trait, so it can't go in a trait object2121
#[cfg(not(target_family = "wasm"))]22
-type SourceError = dyn std::error::Error + Send + Sync;22
+pub(crate) type SourceError = dyn std::error::Error + Send + Sync;2323
#[cfg(target_family = "wasm")]24
-type SourceError = dyn std::error::Error;24
+pub(crate) type SourceError = dyn std::error::Error;2525
2626
/// the one thing that goes wrong, for commands and queries alike. match on `kind`, show `message`2727
#[derive(Clone, Debug, thiserror::Error)]@@ -9,10 +9,15 @@ mod transport;
99
mod uiaa;1010
mod upload;1111
12
-#[cfg(feature = "sqlite")]12
+#[cfg(all(feature = "sqlite", target_family = "wasm"))]13
+compile_error!("sqlite has no filesystem to live on in wasm, use indexeddb");14
+#[cfg(all(feature = "indexeddb", not(target_family = "wasm")))]15
+compile_error!("indexeddb only exists in a browser, use sqlite");16
+17
+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]1318
mod account;1419
15
-#[cfg(feature = "sqlite")]20
+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]1621
pub use account::{1722
AccountError, OAuthApplicationType, OAuthLogin, PasswordLogin, PendingOAuthLogin, Session,1823
SessionRecord, StoreConfig,@@ -76,7 +76,7 @@ impl Future for Pending {
7676
7777
impl ClientHandle {7878
/// current credentials. persist this atomically on `Event::SessionTokensChanged`, it holds secrets79
- #[cfg(feature = "sqlite")]79
+ #[cfg(any(feature = "sqlite", feature = "indexeddb"))]8080
pub fn session_record(&self) -> Result<crate::SessionRecord, crate::AccountError> {8181
crate::account::session_record(&self.client)8282
}@@ -27,7 +27,7 @@ async fn password_session_round_trips_through_an_encrypted_store() {
2727
let session = Session::login_password(2828
PasswordLogin::new(server.uri(), "example", "wordpass")2929
.with_device_display_name("macltok test"),30
- StoreConfig::new(&store_path, "test store passphrase"),30
+ StoreConfig::sqlite(&store_path, "test store passphrase"),3131
)3232
.await3333
.unwrap();@@ -48,7 +48,7 @@ async fn password_session_round_trips_through_an_encrypted_store() {
4848
4949
let session = Session::restore(5050
record,51
- StoreConfig::new(&store_path, "test store passphrase"),51
+ StoreConfig::sqlite(&store_path, "test store passphrase"),5252
)5353
.await5454
.unwrap();