atagen

macltok

public
Star0
Watch0
673563a76e43035cc5e30359bd79c23190068b4a
3
ssh://brewery@pls.lobotomise.me:33/atagen/macltok.git
README.mdMarkdown134 lines6.5 KB

macltok

macltok is a compact Rust application backend built above matrix-sdk and matrix-sdk-ui. It aims to provide the stateful behavior needed by a useful Matrix client without depending on a GUI toolkit or inventing another protocol SDK.

The crate currently provides

  • typed commands, owned UI-safe events and bounded per-client transport
  • Sliding Sync with normalized connection and room-list state
  • independently retained live room timelines and backwards pagination
  • text, emote, reply, edit, redaction, reaction, receipt and typing operations
  • raw encrypted or unencrypted media acquisition with configurable byte limits
  • encryption state, recovery-key import and SAS verification
  • password login and session restoration with caller-owned encrypted SQLite or IndexedDB stores

Matrix room, event, user, device and transaction identifiers remain strongly typed. A toolkit timeline identity covers local echoes without pretending they already have remote event IDs.

Consuming the engine

spawn Engine::run, drain the EventStream from its own task, and talk to the engine through ClientHandle. every command answers once, awaited (handle.send_message(..).await?) or as a Pending you can await later or drop. a sent message answers when the sdk send queue has it, delivery shows up as the timeline item’s send state. reads like rooms, members and media go straight to the sdk. the stream only carries state

some commands put an event on the stream before they answer. Open, Close, OpenThread, CloseThread, Resync, Paginate, Logout and the verification actions all do. the stream is bounded, so a task that drains it and awaits one of those answers deadlocks once it fills. from the draining task, try_send the command and drop the Pending, the events tell you how it went

start a timeline from TimelineReset, then apply each TimelineChanged whole with TimelineDiff::try_apply_all. snapshots are imbl::Vectors, re-exported as macltok::imbl, so a copy costs nothing. when a batch doesn’t fit none of it lands. stop applying, call resync, and pick up from the next TimelineReset

with sqlite or indexeddb, a SessionStore holds one login’s record and its store. EncryptedSessionStore seals the record with the store’s passphrase, in a file you pick natively or an IndexedDB database beside the store in a browser. logging in saves the first record and Session::restore loads it. on every SessionTokensChanged, save a fresh ClientHandle::session_record() before the next refresh, refresh tokens only work once

Engine::run resolves to a Termination saying why it stopped, and Termination::retain says what to do about it. after LoggedOut or AccessRevoked the record and the store are dead, discard them once the client is dropped, since an open store won’t go quietly. SoftLoggedOut wants the store kept and a fresh login as the same device, or the e2ee keys go with it. soft_log_out swaps the record for one naming that device, so a restart gets Restore::SoftLoggedOut and SoftLogout::password_login rather than nothing, and the login overwrites it with a live record. ShutDown and HandlesDropped keep everything. an Err is a real failure and kills nothing. one store directory per login, the sdk won’t share a crypto store between devices

The mirror

the loop above is easy to get subtly wrong, so the mirror feature ships one. Mirror::new takes a ClientHandle and the events, and Mirror::run drains them into one State with the rooms, every open timeline, connection, encryption, verification, typing, uploads and where the session stands. spawn it the way you spawn Engine::run. it takes any stream of events, so wrap the EventStream if you want to watch the raw ones go past

MirrorHandle is cheap to clone, one per frontend. snapshot hands back the current State, and subscribe opens with the whole State and then every Update after it, numbered, with nothing missed and nothing twice. a timeline update carries its diffs and the items they left, so apply the diffs or re-render the list, whichever suits. a subscriber that falls behind gets a fresh State instead of an error

a batch that doesn’t fit leaves the timeline diverged at its last good items. the mirror asks for the resync itself, off the draining path, and keeps asking until the reset or a close turns up. rooms are refetched on RoomsChanged, one fetch at a time with at most one more owed

with a store feature, with_session_store saves the record on every SessionTokensChanged before the next event is touched, and a failed save shows up as an update. without one, catch SessionTokensChanged on the raw stream and save it yourself, a subscriber that lags could miss it. save is all the mirror calls. soft_log_out and discard wait for Engine::run’s Termination once the client is gone

wasm

macltok builds for wasm32-unknown-unknown. the indexeddb feature gives you StoreConfig::indexeddb in place of StoreConfig::sqlite, and asking for either on the wrong target won’t compile. Engine::run hands back a LocalBoxFuture there, so spawn it with wasm_bindgen_futures::spawn_local

getrandom picks its browser backend from a cfg flag, and .cargo/config.toml only reaches this crate’s own builds. every cdylib that pulls macltok in needs it too

[target.wasm32-unknown-unknown]
rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""]

what’s different in a browser

  • a panic aborts the whole instance. natively a panicking command costs its caller an Interrupted, here it takes the engine and everything else with it
  • MediaUploadProgress only ever reports completion, the sdk’s wasm http path has no upload progress
  • the sdk doesn’t retry requests or time them out individually. sync still restarts itself with backoff, that part is ours
  • two tabs or workers on one store each need StoreConfig::with_lock_holder with a name of their own, or they both think they hold the crypto store

Dependency policy

UI frameworks, image decoders, application-directory discovery and logging subscribers do not belong in this crate. SQLite remains optional through the sqlite and bundled-sqlite features, IndexedDB through indexeddb. Media decoding belongs to the consumer.

Verification

nix develop --command cargo fmt -- --check
nix develop --command cargo test --all-targets
nix develop --command cargo test --all-targets --features sqlite
nix develop --command cargo clippy --all-targets -- -D warnings
nix develop --command cargo clippy --all-targets --features sqlite -- -D warnings
nix flake check