Technical Specification — Native Rust Telegram Client
Источник:
dev/fera/project-design/context/TECHSPEC.md(read-only; сгенерировано scripts/sync-docs.mjs)
Technical Specification — Native Rust Telegram Client
Section titled “Technical Specification — Native Rust Telegram Client”Companion docs: ADR.md (why), RFC.md (what/how, system-level),
QA.md (quality gates), OQ.md (open questions blocking specific work).
This document is the concrete, buildable breakdown. Each section maps to roughly one implementation phase / workstream. Crate choices reference ADR.md entries; re-verify maturity/status of any crate before depending on it in production, especially ones flagged “young” below — ecosystem status changes over time and this spec may predate a re-check.
0. Repository / workspace layout (proposed)
Section titled “0. Repository / workspace layout (proposed)”/launcher/ — thin launcher binary (ADR-013)/core/ — core module ("module zero") /crates/ protocol/ — grammers wrapper, typestate client (§1) domain/ — actors, chat/session/media supervisors (§2) storage/ — StorageEngine trait + sanakirja impl (§3) search/ — SearchEngine trait + milli-fork impl (§3) media/ — fetch/decode/cache pipeline (§4) predictor/ — activity logging + tiered heuristics (§5) ui-contract/ — HoF View/Element trait definitions, Msg/Effect ui-elm/ — central Elm update loop + State notification/ — cross-account NotificationActor trust/ — manifest parsing, signature verify, key store/modules/ /storage-sanakirja/ — nylon-ring cdylib /search-milli/ — nylon-ring cdylib /renderer-gpui/ — Engine axis, nylon-ring cdylib /style-macos/ /style-win/ /style-linux/ — Style axis, nylon-ring cdylib /feature-macos/ /feature-win/ /feature-linux/ — Feature axis, nylon-ring cdylib /native-macos/ /native-linux-gtk/ /native-winui/ — Native axis (ADR-023), nylon-ring cdylib /media-decoder-baseline/ — pure-Rust decoders, nylon-ring cdylib /media-decoder-ffmpeg/ — optional, ffmpeg-next, nylon-ring cdylib /calls-tgcalls/ — rstgcalls bindings, nylon-ring cdylib/plugins-sdk/ — wit interface defs + wasmtime host scaffolding/tools/ /manifest-signer/ — CLI for signing the trust manifest /bench-scene/ — fixed benchmark scene generator/fixtures1. Protocol layer
Section titled “1. Protocol layer”Crate: grammers-client (+ grammers-tl-types if low-level access
needed).
Contract (interface crate, protocol-contract):
trait ChatRepository { fn get_chats(&self) -> Stream<ChatUpdate>; fn send_message(&self, chat_id: ChatId, text: Message) -> Future<MessageId>; fn paginate_history(&self, chat_id: ChatId, before: MessageId, limit: u32) -> Future<Vec<Message>>; fn mark_read(&self, chat_id: ChatId, up_to: MessageId) -> Future<()>; // ... extend as needed; keep batch-shaped per ADR-005}Typestate client (ADR-015):
struct ProtocolClient<S> { /* ... */ _state: PhantomData<S> }struct Unauthenticated;struct Authenticated { session: Session }struct Connected { session: Session, dc: DataCenter }
impl ProtocolClient<Unauthenticated> { fn login(self, creds: Credentials) -> Result<ProtocolClient<Authenticated>, LoginError>;}impl ProtocolClient<Authenticated> { fn connect(self) -> Result<ProtocolClient<Connected>, ConnectError>;}impl ProtocolClient<Connected> { fn send_message(&self, ...) -> ...; // only exists here}Reliability:
- Reconnect: exponential backoff with jitter (avoid thundering herd on mass reconnect events).
- Health-check: lightweight, independent of MTProto request timeout (e.g. raw TCP reachability probe to a known endpoint).
- Session persisted via
grammers::Session, owned by the Session/Auth actor, not ad-hoc file access elsewhere.
Update loop: one task per ProtocolActor running
client.next_update().await in a loop, dispatching to Chat/Media actors by
chat_id.
2. Domain / Actor layer
Section titled “2. Domain / Actor layer”Crate: ractor.
Actor tree (proposed):
RootSupervisor├── SessionAuthActor├── ProtocolActor├── ChatSupervisor│ └── ChatActor (per chat_id, dynamic children)├── MediaSupervisor│ ├── FetchActor (pool)│ └── DecodeActor (pool)├── PredictorActor└── NotificationActorChat actor responsibilities: message state (optimistic-update aware),
pagination, ReadState enum (ADR-011), outbox integration.
Outbox model:
enum OutboxStatus { Pending, Sent(MessageId), Failed(RetryInfo) }Persisted in storage, not just in-memory — must survive app restart while offline.
Message passing: raw Rust types between actors, no serialization
(ADR-007). mpsc/flume channel from ProtocolActor into the Elm-loop’s
effect dispatcher on the UI side.
3. Storage & Search
Section titled “3. Storage & Search”Storage: sanakirja behind StorageEngine trait (ADR-012). Trait must
be batch-shaped (ADR-005):
trait StorageEngine { fn get_messages(&self, chat_id: ChatId, from: MessageId, limit: u32) -> Vec<Encrypted<Message>>; fn put_messages(&mut self, chat_id: ChatId, msgs: &[Encrypted<Message>]); fn get_outbox(&self) -> Vec<OutboxEntry>; // ...}Search: milli fork retargeted from heed/LMDB to sanakirja
(ADR-012). Track upstream milli’s heed-facing surface for merge
conflicts on updates. Tokenizer: Charabia.
Regex search (two-phase, ADR-012):
regex-syntaxextracts literal prefixes/substrings from the query.- FTS index (milli) narrows candidates using the literals.
pcre2matches the full pattern only against candidates.
Encryption (ADR-016):
struct Encrypted<T> { data: Vec<u8>, _marker: PhantomData<T> }struct Plaintext<T> { data: T }fn decrypt<T>(e: Encrypted<T>, key: &Key) -> Plaintext<T>;- Metadata tier: ChaCha20 or AES-GCM (AES-NI).
- Content tier: XChaCha20-Poly1305, key via Argon2id from passcode (+ optional OS keychain).
- Open: which fields count as “metadata” vs “content” for ambiguous
cases (e.g. chat list preview text) — see
OQ.md.
Host-side cache: LRU in front of StorageEngine, sized by memory not
item count (message payloads vary in size), invalidated on
write-through from Chat actor.
4. Media pipeline
Section titled “4. Media pipeline”Sub-crates: media-fetch, media-decode, media-cache.
MediaDecoder trait, pluggable per format family:
trait MediaDecoder { fn decode_frame(&self, input: &[u8], format: MediaFormat) -> DecodedFrame;}Baseline (pure Rust, ADR-017): rav1e/dav1d (AV1), openh264
(H.264), symphonia (audio/Opus for voice messages), image (static).
Optional module: ffmpeg-next-backed decoder, shipped as a separate
signed nylon-ring module, downloaded on-demand when the baseline decoder
reports an unsupported format.
Lottie/TGS:
tlottie— CPU/SIMD, default.velato(Vello/wgpu) — experimental alternate backend, same trait; A/B againsttlottieon the fixed benchmark scene (§7 of RFC, scene spec TBD inOQ.md) before deciding default.- Sticker frame cache: pre-decoded frames written to an mmap’d file
(
memmap2), keyed by content hash (notfile_id). Format:Decide raw-vs-compressed-per-frame tradeoff (mmap benefit vs CPU decompress cost) empirically — seeheader { frame_count: u32, width: u16, height: u16,frame_offsets: [u64; frame_count],frame_duration_ms: [u16; frame_count] }<raw or per-frame-compressed RGBA frames>OQ.md.
Voice/video calls (ADR-018): rstgcalls binding crate wrapping
tgcalls (C++ FFI), shipped as its own nylon-ring module
(calls-tgcalls), isolated from the rest of the media pipeline.
5. Predictive prefetch
Section titled “5. Predictive prefetch”Crate: predictor (part of core), backed by the same storage engine
for its event log (or sanakirja directly if kept separate from message
data — TBD, see OQ.md).
Event schema:
struct ChatActivityEvent { chat_id: ChatId, event_type: ActivityEventType, // Opened | Closed | Scrolled | MessageSent timestamp: DateTime<Utc>, session_duration: Option<Duration>,}Tier 1 (build first): exponentially-smoothed open-time histograms (time-of-day × day-of-week), chat co-occurrence pairs, top-N prefetch on chat-list render.
Tier 2 (after Tier 1 produces real data): contextual feature model (time, day, chat type) → P(open within N minutes); simple model (logistic regression / naive Bayes) trained per-user on the Tier 1 log.
Tier 3 (after Tier 2 validated): online-learning refinement (bandit- style prefetch budget allocation).
Priority rule (hard requirement): predictor-issued fetch requests are
always dispatched at lower priority than explicit user-driven fetches, at
the MediaSupervisor/FetchActor queue level.
Substrate: Vello + Parley + winit + AccessKit (ADR-028), with native text-input slots from the Native axis (ADR-023). Low-level paint + hit-test on our own engine; IME/spell-check/emoji/selection delegated to the platform.
Central state: Elm-style loop, ui-elm crate.
enum Msg { /* UI events */ }enum Effect { /* actor-facing effects, e.g. SendMessage, LoadHistory */ }fn update(state: &mut State, msg: Msg) -> Vec<Effect>;Pure, unit-tested without GPUI context (ADR-008, ADR-014).
HoF rendering layer (ui-contract): component functions compose into a
render tree from &State; decide single-pass (build hit-test tree in the
same pass as visual tree, à la immediate-mode) vs. two-pass before
significant implementation investment — see OQ.md.
UI axes (ADR-009 + ADR-023 + ADR-029):
- Engine:
renderer-vello(Vello + Parley + winit + AccessKit, ADR-028); nylon-ring dylib; trait boundary designed to allow alternative engines later. - Style: declarative data, not a dylib (ADR-029) — RON token
files (
colors.ron,typography.ron,spacing.ron,geometry.ron, icons), per-OS aesthetic sets; structurally identical to the Latticethemes/subtree. Special style logic, if ever needed, is described declaratively in the data and implemented in the Feature dylib, selected by OS where relevant. - Feature:
feature-macos/feature-win/feature-linux— OS- integration behaviors (scroll physics, native menu/notification integration); may hook into Engine’s scroll handling. Local physics state (e.g. overscroll spring) stays inside the Feature module, not in centralState. - Native:
native-macos/native-linux-gtk/native-winui— platform-native components behind a thin slot contract (text input, sidebar, menus, dialogs, popups), not whole widget trees. macOS embeds native views into our window (SwiftUI via@_cdeclSwift shim, AppKit viaobjc2); on some platforms ownership is reversed (toolkit owns the window, our renderer is a child view). The message list stays on our own renderer — no native views inside the message scroll.
Frame-rate budgets (separate, deliberately not shared): ~24 fps for UI chrome, ~45 fps for message-list updates, 60–75 fps for stickers and animated content. Each path is scheduled and measured against its own budget; paths with different budgets never share one frame loop.
Local UI mechanics (hover, tooltip, single dropdown open/closed) —
native GPUI Models, not routed through Msg/update.
7. Module system implementation
Section titled “7. Module system implementation”Native modules: nylon-ring (host + define_plugin! macro on the
module side). Required guarantees to rely on: explicit version/struct-size
validation at load, graceful unload draining in-flight calls, host-leased
output buffers for zero-copy where used.
Struct-layer granularity (ADR-020): modules may be cut at individual
domain-structure level, in three tiers with explicit manifest metadata
(layer: shared | types | impl, depends_on), loaded in topological
order:
shared-types.dll (no dependencies, most stable layer) ↑for-X-types.dll (depends on shared-types) ↑X.dll (depends on for-X-types + shared-types)- Flat hot-path types (
MessageId,ChatId) are#[repr(C)], passed by value across the ABI — no per-field boundary calls. - One struct = one physical seam: all of a type’s impl swaps together.
- Strongly-connected types (Message ↔ Chat ↔ ReadState) share one module; genuinely independent types stay separate.
WASM plugins: wasmtime host + wit-bindgen/Component Model for typed
interfaces. Capability surface declared at the interface level:
interface plugin-readonly { get-chat-list: func() -> list<chat-summary>; get-messages: func(chat-id: u64, from: u64, limit: u32) -> list<message>;}interface plugin-write { // only linked in if capability granted send-message: func(chat-id: u64, text: string) -> result<message-id, error>;}Read-only interface linked by default; write/action interfaces only linked into the plugin’s instance if the corresponding capability is enabled in settings — unauthorized capabilities should be absent from the callable surface, not merely policy-checked at call time.
Edition translators (ADR-022): WASM plugins only; native modules are never translated.
- A translator is a separate module type: loaded without special rights, it returns a virtual plugin list; the host resolves actual rights per virtual plugin.
- Level 1 — deterministic declarative mapping (default, trusted): a data table (call renames, field re-laying, default values, a small built-in converter set) interpreted by the host. Chains statically validated at load (cycles, depth) — no timeout or sandbox needed.
- Level 2 — behavioral translation (opt-in, flagged): for what a table cannot express (e.g. sync ↔ async/streaming). Declared per-function via flag; each capability requires a separate explicit user opt-in; still WASM-sandboxed and fuel-limited.
- Rights: read by the host from the signed package manifest, never from translator output; capability IDs are host-defined, frozen, additive-only, must-understand. Final rights = original ∩ user-granted — the translator can rename or drop, never add. The UI shows the original plugin’s rights.
- Plugin container format: open — deferred to the author (OQ-013).
Trust manifest (trust crate):
- Format: JSON (chosen over RON for external-tooling interoperability,
ADR-003) —
{ modules: [{ name, version, hash, signature }], ... }, itself signed with the root Ed25519/SSH key. - Root key embedded in launcher at build time.
- Local trusted-key store: user-manageable list,
{ name, source }wheresourceis a URL or hex-encoded public key. Simple add/remove UI. - Hash mismatch on module load → non-blocking warning surfaced in UI (ADR-004), never a load refusal.
Versioned manifest entry (ADR-021): entries carry full version and targeting detail, hardened:
{ "name": "storage", "module_version": "1.4.2", "api_edition": 3, "abi_version": 7, "host": { "min_version": "0.4.0", "max_version": "0.9.99" }, "target": { "triple": "x86_64-pc-windows-msvc", "runtime": "msvc", "features": ["avx2"] }, "artifacts": [ { "file": "storage-1.4.2-x86_64-pc-windows-msvc.dll", "sha256": "...", "size": 4812390 } ], "fallback": { "module": "storage", "builtin": true }, "depends_on": ["shared-types"], "layer": "impl", "signature": { "algorithm": "ed25519", "key_id": "fera-official-2026", "value": "..." }}- The signature covers the whole entry in canonical serialization —
not just the artifact hash (otherwise
host.max_version/fallbackare substitutable without breaking the signature). - Downgrade protection: minimum allowed version per module + manifest timestamp/expiry.
artifactsis an array: CPU-feature variants (avx2/ baseline), selected by CPU detect at launch.- Key revocation list, keyed by
key_id. - Version axes are independent:
abi_version(binary layout at the boundary) /api_edition(contract semantics; host supports [N, N-1]) /module_version(implementation semver). The on-disk data format versions independently of all three.
Launcher (ADR-013):
- Read local trust manifest cache (or fetch/verify a fresh one against the signed update endpoint baked into the manifest itself, never a plaintext user-editable config).
- Check installed core-module version against manifest.
- If outdated/missing: download, verify signature, replace on-disk file.
dlopencore module, call entry point, hand off control. Launcher itself should change rarely; treat any need to modify launcher logic as a signal to reconsider whether that logic belongs in the core module instead.
8. Multi-account & notifications
Section titled “8. Multi-account & notifications”NotificationActor receives merged update-streams from all active
ProtocolActor instances (one per account). Owns global mute/batching/
priority policy. Notification payload includes account identifier for
per-account iconography in the OS notification, but policy decisions
(should this notify, how to batch) are evaluated globally across accounts,
not per-account in isolation.
Platform notification delivery: notify-rust (Linux/DBus) / native
Win/macOS APIs, each encapsulated inside its respective platform’s Feature
module (§6) rather than as a separate ad-hoc integration point.
9. Testing infrastructure (ADR-014)
Section titled “9. Testing infrastructure (ADR-014)”- Contract tests: one suite per trait (
StorageEngine,SearchEngine,MediaDecoder,Engine), runnable against any conforming implementation — required before a second implementation of any trait is merged. - Actor tests:
ractortest harness, per-actor. - Elm tests: plain
#[test]onupdate, no GPUI context. - Module-loading tests: version mismatch → fallback (not crash); hash mismatch → warning surfaced, load proceeds; hot-reload under concurrent load → zero dropped in-flight calls.
- Visual regression: pixel-diff, scoped per Style module, against reference screenshots derived from official per-OS clients.
- Benchmarks:
criterion, scoped per Engine module, against the fixed benchmark scene (parameters TBD — seeOQ.md; strawman: chat with 5000 messages, 50 concurrently-animating stickers).
10. Sequencing notes (not a committed roadmap, a dependency ordering)
Section titled “10. Sequencing notes (not a committed roadmap, a dependency ordering)”Rough dependency order, not a schedule:
- Protocol layer + typestate client, minimal Chat actor, raw
StorageEngine/sanakirja(no encryption yet) — prove the offline-first read path end to end. - Outbox + reconnect/backoff — prove offline-first write path.
- Elm central loop + minimal GPUI rendering (text-only chat view) — prove the UI data flow before investing in HoF layer polish or pixel-1:1 fidelity.
nylon-ringmodule boundary forStorageEngine— prove hot-swap works before generalizing the pattern to other subsystems.- Media pipeline baseline (images, then video, then Lottie/mmap cache).
- Search (milli-fork), encryption tiers, trust manifest + launcher.
- UI Engine/Style/Feature axis split, HoF rendering layer maturation, pixel-1:1 passes.
- Predictor Tier 1, WASM plugin scaffolding, calls (tgcalls bindings).
- Predictor Tier 2/3, optional FFmpeg module, deferred items from
OQ.mdas they get resolved.