atagen

macltok

public
Star0
Watch0

Commit 62fe32759529

tarball

atagen <boss@atagen.co>2026-09-27 01:42 UTC

11 files changed+119-33
build: add a wasm32 target with an indexeddb store

The store is picked by an explicit sqlite or indexeddb feature rather than by target, and naming one on the wrong target is a compile_error, so a native build can never quietly end up with a browser store. js comes along with any wasm build, which keeps a storeless wasm build working for callers who bring their own client. StoreConfig takes a lock holder because the sdk calls every client main, and two tabs sharing that name on one IndexedDB store would both believe they hold the crypto store lock.
Added file: .cargo/config.toml+2 additions
@@ -0,0 +1,2 @@
1+[target.wasm32-unknown-unknown]
2+rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""]
Modified file: Cargo.lock+8 additions
@@ -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",
Modified file: Cargo.toml+11 additions
@@ -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 needs
36+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"
Modified file: README.md+34 additions−5 deletions
@@ -13,7 +13,8 @@ The crate currently provides
1313 - text, emote, reply, edit, redaction, reaction, receipt and typing operations
1414 - raw encrypted or unencrypted media acquisition with configurable byte limits
1515 - encryption state, recovery-key import and SAS verification
16-- password login and session restoration with caller-owned encrypted SQLite stores
16+- password login and session restoration with caller-owned encrypted SQLite or
17+ IndexedDB stores
1718
1819 Matrix room, event, user, device and transaction identifiers remain strongly
1920 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 its
36-encrypted SQLite store. On `SessionTokensChanged`, obtain a new record from
36+With `sqlite` or `indexeddb`, persist the initial `Session::record()`
37+separately from its encrypted store. On `SessionTokensChanged`, obtain a new record from
3738 `ClientHandle::session_record()` and replace the saved record atomically in
38-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 a
4142 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 is
4647 login-only; it does not persist sessions across runs.
4748
49+## wasm
50+
51+macltok builds for `wasm32-unknown-unknown`. the `indexeddb` feature gives you
52+`StoreConfig::indexeddb` in place of `StoreConfig::sqlite`, and asking for either
53+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 needs
58+it too
59+
60+```toml
61+[target.wasm32-unknown-unknown]
62+rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""]
63+```
64+
65+what's different in a browser
66+
67+- a panic aborts the whole instance. natively a panicking command costs its
68+ caller an `Interrupted`, here it takes the engine and everything else with it
69+- `MediaUploadProgress` only ever reports completion, the sdk's wasm http path
70+ has no upload progress
71+- the sdk doesn't retry requests or time them out individually. sync still
72+ restarts itself with backoff, that part is ours
73+- 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 store
75+
4876 ## Dependency policy
4977
5078 UI frameworks, image decoders, application-directory discovery and logging
5179 subscribers do not belong in this crate. SQLite remains optional through the
52-`sqlite` and `bundled-sqlite` features. Media decoding belongs to the consumer.
80+`sqlite` and `bundled-sqlite` features, IndexedDB through `indexeddb`. Media
81+decoding belongs to the consumer.
5382
5483 ## Verification
5584
Modified file: docs/design-decisions.md+4 additions−3 deletions
@@ -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 registered
3131 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 whole
34+instance on any panic and nothing is left to log it
3435
3536 sync restarts itself with backoff after an error or an unexpected stop, and
3637 `ConnectionChanged` shows `Offline` meanwhile. a run that drops again inside
@@ -141,8 +142,8 @@ watcher there reports them
141142 ## sessions and secrets
142143
143144 `Session::login_password`, `begin_oauth` and `restore` share one client builder
144-with encrypted sqlite, refresh tokens and threads. secret-bearing inputs don't
145-derive `Clone`
145+with an encrypted store, refresh tokens and threads. the store is sqlite
146+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 and
148149 replace it atomically with `ClientHandle::session_record()` on every
Modified file: examples/headless.rs+1 additions−1 deletions
@@ -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 token
1111 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(_) => {
Modified file: src/account.rs+47 additions−17 deletions
@@ -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 lock
189210 #[must_use]
190- pub fn path(&self) -> &Path {
191- &self.path
211+ pub fn with_lock_holder(mut self, holder: impl Into<String>) -> Self {
212+ self.lock_holder = Some(holder.into());
213+ self
192214 }
193215 }
194216
195217 /// Serializable authentication secrets. Store this separately from the
196-/// 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,
Modified file: src/error.rs+2 additions−2 deletions
@@ -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 object
2121 #[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)]
Modified file: src/lib.rs+7 additions−2 deletions
@@ -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,
Modified file: src/transport.rs+1 additions−1 deletions
@@ -76,7 +76,7 @@ impl Future for Pending {
7676
7777 impl ClientHandle {
7878 /// current credentials. persist this atomically on `Event::SessionTokensChanged`, it holds secrets
79- #[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 }
Modified file: tests/account.rs+2 additions−2 deletions
@@ -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 .await
3333 .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 .await
5454 .unwrap();