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 | |
38c4caecdb3d | atagen | 2026-09-26 13:35 UTC | 39 | `prepare_to_send` does the key work a send into an encrypted room would start |
38c4caecdb3d | atagen | 2026-09-26 13:35 UTC | 40 | with, ahead of time, so the send itself is quicker. the engine runs it itself |
38c4caecdb3d | atagen | 2026-09-26 13:35 UTC | 41 | when a room opens or `set_typing(room, true)` comes in, unless |
38c4caecdb3d | atagen | 2026-09-26 13:35 UTC | 42 | `EngineConfig::with_prepare_to_send(false)`. only part of that work is reachable |
38c4caecdb3d | atagen | 2026-09-26 13:35 UTC | 43 | without a matrix-sdk fork, `docs/design-decisions.md` says which |
38c4caecdb3d | atagen | 2026-09-26 13:35 UTC | 44 | |
d5d8097c3afd | atagen | 2026-09-26 09:33 UTC | 45 | start a timeline from `TimelineReset`, then apply each `TimelineChanged` whole |
d5d8097c3afd | atagen | 2026-09-26 09:33 UTC | 46 | with `TimelineDiff::try_apply_all`. snapshots are `imbl::Vector`s, re-exported |
d5d8097c3afd | atagen | 2026-09-26 09:33 UTC | 47 | 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 | 48 | lands. stop applying, call `resync`, and pick up from the next `TimelineReset` |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 49 | |
713205a95af2 | atagen | 2026-09-26 09:24 UTC | 50 | with `sqlite` or `indexeddb`, a `SessionStore` holds one login's record and its |
713205a95af2 | atagen | 2026-09-26 09:24 UTC | 51 | store. `EncryptedSessionStore` seals the record with the store's passphrase, in a |
713205a95af2 | atagen | 2026-09-26 09:24 UTC | 52 | file you pick natively or an IndexedDB database beside the store in a browser. |
f12377bce9b9 | atagen | 2026-09-26 12:32 UTC | 53 | logging in saves the first record and `Session::restore` loads it. from then on |
f12377bce9b9 | atagen | 2026-09-26 12:32 UTC | 54 | the engine the `Session` builds keeps it current. every token rotation is saved |
f12377bce9b9 | atagen | 2026-09-26 12:32 UTC | 55 | before `SessionTokensChanged` goes out, and a save that fails says so with |
f12377bce9b9 | atagen | 2026-09-26 12:32 UTC | 56 | `SessionSaveFailed`. natively `EncryptedSessionStore` writes from inside the |
f12377bce9b9 | atagen | 2026-09-26 12:32 UTC | 57 | sdk's refresh, before the new refresh token is ever used, and a store of your |
f12377bce9b9 | atagen | 2026-09-26 12:32 UTC | 58 | own gets the same by implementing `SessionStore::save_blocking`. in a browser |
f12377bce9b9 | atagen | 2026-09-26 12:32 UTC | 59 | the save can only follow the refresh, so a tab killed in between wakes with a |
f12377bce9b9 | atagen | 2026-09-26 12:32 UTC | 60 | spent token. `Session::without_persistence` hands the job back, and then it's |
f12377bce9b9 | atagen | 2026-09-26 12:32 UTC | 61 | yours to save `ClientHandle::session_record()` on every `SessionTokensChanged` |
fa9114fcf75f | atagen | 2026-09-24 12:51 UTC | 62 | |
fe4133dbcd47 | atagen | 2026-09-26 09:05 UTC | 63 | `Engine::run` resolves to a `Termination` saying why it stopped, and |
fe4133dbcd47 | atagen | 2026-09-26 09:05 UTC | 64 | `Termination::retain` says what to do about it. after `LoggedOut` or |
713205a95af2 | atagen | 2026-09-26 09:24 UTC | 65 | `AccessRevoked` the record and the store are dead, `discard` them once the |
713205a95af2 | atagen | 2026-09-26 09:24 UTC | 66 | client is dropped, since an open store won't go quietly. `SoftLoggedOut` wants |
6f1d6591c10d | atagen | 2026-09-26 09:52 UTC | 67 | 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 | 68 | it. `soft_log_out` swaps the record for one naming that device, so a restart |
6f1d6591c10d | atagen | 2026-09-26 09:52 UTC | 69 | gets `Restore::SoftLoggedOut` and `SoftLogout::password_login` rather than |
6f1d6591c10d | atagen | 2026-09-26 09:52 UTC | 70 | nothing, and the login overwrites it with a live record. `ShutDown` and |
6f1d6591c10d | atagen | 2026-09-26 09:52 UTC | 71 | `HandlesDropped` keep everything. an `Err` is a real failure and kills nothing. |
6f1d6591c10d | atagen | 2026-09-26 09:52 UTC | 72 | one store directory per login, the sdk won't share a crypto store between |
6f1d6591c10d | atagen | 2026-09-26 09:52 UTC | 73 | devices |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 74 | |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 75 | ## The mirror |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 76 | |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 77 | the loop above is easy to get subtly wrong, so the `mirror` feature ships one. |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 78 | `Mirror::new` takes a `ClientHandle` and the events, and `Mirror::run` drains |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 79 | them into one `State` with the rooms, every open timeline, connection, |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 80 | encryption, verification, typing, uploads and where the session stands. spawn |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 81 | it the way you spawn `Engine::run`. it takes any stream of events, so wrap the |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 82 | `EventStream` if you want to watch the raw ones go past |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 83 | |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 84 | `MirrorHandle` is cheap to clone, one per frontend. `snapshot` hands back the |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 85 | current `State`, and `subscribe` opens with the whole `State` and then every |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 86 | `Update` after it, numbered, with nothing missed and nothing twice. a timeline |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 87 | update carries its diffs and the items they left, so apply the diffs or |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 88 | re-render the list, whichever suits. a subscriber that falls behind gets a fresh |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 89 | `State` instead of an error |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 90 | |
0b8ce8dc9a1c | atagen | 2026-09-26 12:30 UTC | 91 | a batch that doesn't fit leaves the timeline diverged at its last good items, |
0b8ce8dc9a1c | atagen | 2026-09-26 12:30 UTC | 92 | and so does a diff for a timeline it never saw reset. the mirror asks for the |
0b8ce8dc9a1c | atagen | 2026-09-26 12:30 UTC | 93 | resync itself, off the draining path, and keeps asking until the reset or a |
0b8ce8dc9a1c | atagen | 2026-09-26 12:30 UTC | 94 | close turns up. rooms are refetched on `RoomsChanged`, one fetch at a time with |
0b8ce8dc9a1c | atagen | 2026-09-26 12:30 UTC | 95 | at most one more owed |
0b8ce8dc9a1c | atagen | 2026-09-26 12:30 UTC | 96 | |
0b8ce8dc9a1c | atagen | 2026-09-26 12:30 UTC | 97 | failures stay in the `State`, so a late subscriber sees them too. what stopped |
0b8ce8dc9a1c | atagen | 2026-09-26 12:30 UTC | 98 | the engine sits in `engine_failure`, and a failed save of the session record in |
0b8ce8dc9a1c | atagen | 2026-09-26 12:30 UTC | 99 | `session_save_failure` until the next one works. the mirror never touches the |
0b8ce8dc9a1c | atagen | 2026-09-26 12:30 UTC | 100 | store |
a319bae745d8 | atagen | 2026-09-26 09:59 UTC | 101 | |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 102 | ## wasm |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 103 | |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 104 | macltok builds for `wasm32-unknown-unknown`. the `indexeddb` feature gives you |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 105 | `StoreConfig::indexeddb` in place of `StoreConfig::sqlite`, and asking for either |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 106 | on the wrong target won't compile. `Engine::run` hands back a `LocalBoxFuture` |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 107 | there, so spawn it with `wasm_bindgen_futures::spawn_local` |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 108 | |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 109 | getrandom picks its browser backend from a cfg flag, and `.cargo/config.toml` |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 110 | only reaches this crate's own builds. every cdylib that pulls macltok in needs |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 111 | it too |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 112 | |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 113 | ```toml |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 114 | [target.wasm32-unknown-unknown] |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 115 | rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""] |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 116 | ``` |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 117 | |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 118 | what's different in a browser |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 119 | |
7b376d620a3d | atagen | 2026-09-26 12:53 UTC | 120 | - a panic doesn't abort anything. it traps the task mid poll and the rest of |
7b376d620a3d | atagen | 2026-09-26 12:53 UTC | 121 | the engine carries on in an undefined state and goes quiet, no `Failed` and no |
7b376d620a3d | atagen | 2026-09-26 12:53 UTC | 122 | `Stopped`. register `macltok::on_panic` and have it tear the worker down and |
7b376d620a3d | atagen | 2026-09-26 12:53 UTC | 123 | start another. natively a panicking command just costs its caller an |
7b376d620a3d | atagen | 2026-09-26 12:53 UTC | 124 | `Interrupted` |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 125 | - `MediaUploadProgress` only ever reports completion, the sdk's wasm http path |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 126 | has no upload progress |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 127 | - the sdk doesn't retry requests or time them out individually. sync still |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 128 | restarts itself with backoff, that part is ours |
a250c7fa43fb | atagen | 2026-09-26 13:35 UTC | 129 | - an OAuth login only finishes in the process that began it, since the sdk |
a250c7fa43fb | atagen | 2026-09-26 13:35 UTC | 130 | keeps its pending state in memory. run it in a popup, or in a worker that |
a250c7fa43fb | atagen | 2026-09-26 13:35 UTC | 131 | outlives the redirect, not by navigating the page away |
a250c7fa43fb | atagen | 2026-09-26 13:35 UTC | 132 | - one engine per store. a second tab or worker opening a store that's in use |
a250c7fa43fb | atagen | 2026-09-26 13:35 UTC | 133 | gets `StoreInUse` straight away, so serve many tabs from one engine in a |
a250c7fa43fb | atagen | 2026-09-26 13:35 UTC | 134 | SharedWorker rather than one each. the store's Web Lock needs a secure |
a250c7fa43fb | atagen | 2026-09-26 13:35 UTC | 135 | context |
6db49c0ecf71 | atagen | 2026-09-26 12:41 UTC | 136 | - `discard` while another tab still has the store open answers |
6db49c0ecf71 | atagen | 2026-09-26 12:41 UTC | 137 | `StoreDeletePending`. the delete lands once that tab lets go, and reopening |
6db49c0ecf71 | atagen | 2026-09-26 12:41 UTC | 138 | the store waits behind it |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 139 | |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 140 | ## Dependency policy |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 141 | |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 142 | UI frameworks, image decoders, application-directory discovery and logging |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 143 | subscribers do not belong in this crate. SQLite remains optional through the |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 144 | `sqlite` and `bundled-sqlite` features, IndexedDB through `indexeddb`. Media |
62fe32759529 | atagen | 2026-09-26 08:50 UTC | 145 | decoding belongs to the consumer. |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 146 | |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 147 | ## Verification |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 148 | |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 149 | ```sh |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 150 | nix develop --command cargo fmt -- --check |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 151 | nix develop --command cargo test --all-targets |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 152 | nix develop --command cargo test --all-targets --features sqlite |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 153 | nix develop --command cargo clippy --all-targets -- -D warnings |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 154 | nix develop --command cargo clippy --all-targets --features sqlite -- -D warnings |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 155 | nix flake check |
96fdf90aacd6 | atagen | 2026-09-08 08:24 UTC | 156 | ``` |
a33777af1c26 | atagen | 2026-09-26 12:54 UTC | 157 | |
a33777af1c26 | atagen | 2026-09-26 12:54 UTC | 158 | `--all-features` doesn't build and isn't meant to. `sqlite` and `indexeddb` each |
a33777af1c26 | atagen | 2026-09-26 12:54 UTC | 159 | belong to one target and refuse the other |