96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 1 | # macltok |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 2 | |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 3 | `macltok` is a compact Rust application backend built above `matrix-sdk` and |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 4 | `matrix-sdk-ui`. It aims to provide the stateful behavior needed by a useful |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 5 | Matrix client without depending on a GUI toolkit or inventing another protocol |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 6 | SDK. |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 7 | |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 8 | The crate currently provides |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 9 | |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 10 | - typed commands, owned UI-safe events and bounded per-client transport |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 11 | - Sliding Sync with normalized connection and room-list state |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 12 | - independently retained live room timelines and backwards pagination |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 13 | - text, emote, reply, edit, redaction, reaction, receipt and typing operations |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 14 | - raw encrypted or unencrypted media acquisition with configurable byte limits |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 15 | - encryption state, recovery-key import and SAS verification |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 16 | - password login and session restoration with caller-owned encrypted SQLite or |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 17 | IndexedDB stores |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 18 | |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 19 | Matrix room, event, user, device and transaction identifiers remain strongly |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 20 | typed. A toolkit timeline identity covers local echoes without pretending they |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 21 | already have remote event IDs. |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 22 | |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 23 | ## Consuming the engine |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 24 | |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 25 | spawn `Engine::run`, drain the `EventStream` from its own task, and talk to the |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 26 | engine through `ClientHandle`. every command answers once, awaited |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 27 | (`handle.send_message(..).await?`) or as a `Pending` you can await later or |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 28 | drop. a sent message answers when the sdk send queue has it, delivery shows up |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 29 | as the timeline item's send state. reads like `rooms`, `members` and `media` |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 30 | go straight to the sdk. the stream only carries state |
31ce2453c82f | atagen | 2026-09-24 13:07 UTC | 31 | |
b2b7e7479384 | atagen | 2026-09-26 09:40 UTC | 32 | some commands put an event on the stream before they answer. `Open`, `Close`, |
b2b7e7479384 | atagen | 2026-09-26 09:40 UTC | 33 | `OpenThread`, `CloseThread`, `Resync`, `Paginate`, `Logout` and the |
b2b7e7479384 | atagen | 2026-09-26 09:40 UTC | 34 | verification actions all do. the stream is bounded, so a task that drains it |
b2b7e7479384 | atagen | 2026-09-26 09:40 UTC | 35 | and awaits one of those answers deadlocks once it fills. from the draining |
b2b7e7479384 | atagen | 2026-09-26 09:40 UTC | 36 | task, `try_send` the command and drop the `Pending`, the events tell you how it |
b2b7e7479384 | atagen | 2026-09-26 09:40 UTC | 37 | went |
b2b7e7479384 | atagen | 2026-09-26 09:40 UTC | 38 | |
d5d8097c3afd | atagen | 2026-09-26 09:33 UTC | 39 | start a timeline from `TimelineReset`, then apply each `TimelineChanged` whole |
d5d8097c3afd | atagen | 2026-09-26 09:33 UTC | 40 | with `TimelineDiff::try_apply_all`. snapshots are `imbl::Vector`s, re-exported |
d5d8097c3afd | atagen | 2026-09-26 09:33 UTC | 41 | as `macltok::imbl`, so a copy costs nothing. when a batch doesn't fit none of it |
d5d8097c3afd | atagen | 2026-09-26 09:33 UTC | 42 | lands. stop applying, call `resync`, and pick up from the next `TimelineReset` |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 43 | |
713205a95af2 | atagen | 2026-09-26 09:24 UTC | 44 | with `sqlite` or `indexeddb`, a `SessionStore` holds one login's record and its |
713205a95af2 | atagen | 2026-09-26 09:24 UTC | 45 | store. `EncryptedSessionStore` seals the record with the store's passphrase, in a |
713205a95af2 | atagen | 2026-09-26 09:24 UTC | 46 | file you pick natively or an IndexedDB database beside the store in a browser. |
f12377bce9b9 | atagen | 2026-09-26 12:32 UTC | 47 | logging in saves the first record and `Session::restore` loads it. from then on |
f12377bce9b9 | atagen | 2026-09-26 12:32 UTC | 48 | the engine the `Session` builds keeps it current. every token rotation is saved |
f12377bce9b9 | atagen | 2026-09-26 12:32 UTC | 49 | before `SessionTokensChanged` goes out, and a save that fails says so with |
f12377bce9b9 | atagen | 2026-09-26 12:32 UTC | 50 | `SessionSaveFailed`. natively `EncryptedSessionStore` writes from inside the |
f12377bce9b9 | atagen | 2026-09-26 12:32 UTC | 51 | sdk's refresh, before the new refresh token is ever used, and a store of your |
f12377bce9b9 | atagen | 2026-09-26 12:32 UTC | 52 | own gets the same by implementing `SessionStore::save_blocking`. in a browser |
f12377bce9b9 | atagen | 2026-09-26 12:32 UTC | 53 | the save can only follow the refresh, so a tab killed in between wakes with a |
f12377bce9b9 | atagen | 2026-09-26 12:32 UTC | 54 | spent token. `Session::without_persistence` hands the job back, and then it's |
f12377bce9b9 | atagen | 2026-09-26 12:32 UTC | 55 | yours to save `ClientHandle::session_record()` on every `SessionTokensChanged` |
fa9114fcf75f | atagen | 2026-09-24 12:51 UTC | 56 | |
fe4133dbcd47 | atagen | 2026-09-26 09:05 UTC | 57 | `Engine::run` resolves to a `Termination` saying why it stopped, and |
fe4133dbcd47 | atagen | 2026-09-26 09:05 UTC | 58 | `Termination::retain` says what to do about it. after `LoggedOut` or |
713205a95af2 | atagen | 2026-09-26 09:24 UTC | 59 | `AccessRevoked` the record and the store are dead, `discard` them once the |
713205a95af2 | atagen | 2026-09-26 09:24 UTC | 60 | client is dropped, since an open store won't go quietly. `SoftLoggedOut` wants |
6f1d6591c10d | atagen | 2026-09-26 09:52 UTC | 61 | the store kept and a fresh login as the same device, or the e2ee keys go with |
6f1d6591c10d | atagen | 2026-09-26 09:52 UTC | 62 | it. `soft_log_out` swaps the record for one naming that device, so a restart |
6f1d6591c10d | atagen | 2026-09-26 09:52 UTC | 63 | gets `Restore::SoftLoggedOut` and `SoftLogout::password_login` rather than |
6f1d6591c10d | atagen | 2026-09-26 09:52 UTC | 64 | nothing, and the login overwrites it with a live record. `ShutDown` and |
6f1d6591c10d | atagen | 2026-09-26 09:52 UTC | 65 | `HandlesDropped` keep everything. an `Err` is a real failure and kills nothing. |
6f1d6591c10d | atagen | 2026-09-26 09:52 UTC | 66 | one store directory per login, the sdk won't share a crypto store between |
6f1d6591c10d | atagen | 2026-09-26 09:52 UTC | 67 | devices |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 68 | |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 69 | ## The mirror |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 70 | |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 71 | the loop above is easy to get subtly wrong, so the `mirror` feature ships one. |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 72 | `Mirror::new` takes a `ClientHandle` and the events, and `Mirror::run` drains |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 73 | them into one `State` with the rooms, every open timeline, connection, |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 74 | encryption, verification, typing, uploads and where the session stands. spawn |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 75 | it the way you spawn `Engine::run`. it takes any stream of events, so wrap the |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 76 | `EventStream` if you want to watch the raw ones go past |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 77 | |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 78 | `MirrorHandle` is cheap to clone, one per frontend. `snapshot` hands back the |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 79 | current `State`, and `subscribe` opens with the whole `State` and then every |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 80 | `Update` after it, numbered, with nothing missed and nothing twice. a timeline |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 81 | update carries its diffs and the items they left, so apply the diffs or |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 82 | re-render the list, whichever suits. a subscriber that falls behind gets a fresh |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 83 | `State` instead of an error |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 84 | |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 85 | a batch that doesn't fit leaves the timeline diverged at its last good items. |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 86 | the mirror asks for the resync itself, off the draining path, and keeps asking |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 87 | until the reset or a close turns up. rooms are refetched on `RoomsChanged`, one |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 88 | fetch at a time with at most one more owed |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 89 | |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 90 | with a store feature, `with_session_store` saves the record on every |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 91 | `SessionTokensChanged` before the next event is touched, and a failed save shows |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 92 | up as an update. without one, catch `SessionTokensChanged` on the raw stream |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 93 | and save it yourself, a subscriber that lags could miss it. `save` is all the |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 94 | mirror calls. `soft_log_out` and `discard` wait for `Engine::run`'s `Termination` |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 95 | once the client is gone |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 96 | |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 97 | ## wasm |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 98 | |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 99 | macltok builds for `wasm32-unknown-unknown`. the `indexeddb` feature gives you |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 100 | `StoreConfig::indexeddb` in place of `StoreConfig::sqlite`, and asking for either |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 101 | on the wrong target won't compile. `Engine::run` hands back a `LocalBoxFuture` |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 102 | there, so spawn it with `wasm_bindgen_futures::spawn_local` |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 103 | |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 104 | getrandom picks its browser backend from a cfg flag, and `.cargo/config.toml` |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 105 | only reaches this crate's own builds. every cdylib that pulls macltok in needs |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 106 | it too |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 107 | |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 108 | ```toml |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 109 | [target.wasm32-unknown-unknown] |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 110 | rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""] |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 111 | ``` |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 112 | |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 113 | what's different in a browser |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 114 | |
7b376d620a3d | atagen | 2026-09-26 12:53 UTC | 115 | - a panic doesn't abort anything. it traps the task mid poll and the rest of |
7b376d620a3d | atagen | 2026-09-26 12:53 UTC | 116 | the engine carries on in an undefined state and goes quiet, no `Failed` and no |
7b376d620a3d | atagen | 2026-09-26 12:53 UTC | 117 | `Stopped`. register `macltok::on_panic` and have it tear the worker down and |
7b376d620a3d | atagen | 2026-09-26 12:53 UTC | 118 | start another. natively a panicking command just costs its caller an |
7b376d620a3d | atagen | 2026-09-26 12:53 UTC | 119 | `Interrupted` |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 120 | - `MediaUploadProgress` only ever reports completion, the sdk's wasm http path |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 121 | has no upload progress |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 122 | - the sdk doesn't retry requests or time them out individually. sync still |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 123 | restarts itself with backoff, that part is ours |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 124 | - two tabs or workers on one store each need `StoreConfig::with_lock_holder` |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 125 | with a name of their own, or they both think they hold the crypto store |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 126 | |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 127 | ## Dependency policy |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 128 | |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 129 | UI frameworks, image decoders, application-directory discovery and logging |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 130 | subscribers do not belong in this crate. SQLite remains optional through the |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 131 | `sqlite` and `bundled-sqlite` features, IndexedDB through `indexeddb`. Media |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 132 | decoding belongs to the consumer. |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 133 | |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 134 | ## Verification |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 135 | |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 136 | ```sh |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 137 | nix develop --command cargo fmt -- --check |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 138 | nix develop --command cargo test --all-targets |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 139 | nix develop --command cargo test --all-targets --features sqlite |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 140 | nix develop --command cargo clippy --all-targets -- -D warnings |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 141 | nix develop --command cargo clippy --all-targets --features sqlite -- -D warnings |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 142 | nix flake check |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 143 | ``` |
a33777af1c26 | atagen | 2026-09-26 12:54 UTC | 144 | |
a33777af1c26 | atagen | 2026-09-26 12:54 UTC | 145 | `--all-features` doesn't build and isn't meant to. `sqlite` and `indexeddb` each |
a33777af1c26 | atagen | 2026-09-26 12:54 UTC | 146 | belong to one target and refuse the other |