Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

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/fixtures

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.


Crate: ractor.

Actor tree (proposed):

RootSupervisor
├── SessionAuthActor
├── ProtocolActor
├── ChatSupervisor
│ └── ChatActor (per chat_id, dynamic children)
├── MediaSupervisor
│ ├── FetchActor (pool)
│ └── DecodeActor (pool)
├── PredictorActor
└── NotificationActor

Chat 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.


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):

  1. regex-syntax extracts literal prefixes/substrings from the query.
  2. FTS index (milli) narrows candidates using the literals.
  3. pcre2 matches 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.


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 against tlottie on the fixed benchmark scene (§7 of RFC, scene spec TBD in OQ.md) before deciding default.
  • Sticker frame cache: pre-decoded frames written to an mmap’d file (memmap2), keyed by content hash (not file_id). Format:
    header { 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>
    Decide raw-vs-compressed-per-frame tradeoff (mmap benefit vs CPU decompress cost) empirically — see 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.


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 Lattice themes/ 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 central State.
  • 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 @_cdecl Swift shim, AppKit via objc2); 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.


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 } where source is 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/fallback are substitutable without breaking the signature).
  • Downgrade protection: minimum allowed version per module + manifest timestamp/expiry.
  • artifacts is 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):

  1. 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).
  2. Check installed core-module version against manifest.
  3. If outdated/missing: download, verify signature, replace on-disk file.
  4. dlopen core 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.

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.


  • 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: ractor test harness, per-actor.
  • Elm tests: plain #[test] on update, 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 — see OQ.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:

  1. Protocol layer + typestate client, minimal Chat actor, raw StorageEngine/sanakirja (no encryption yet) — prove the offline-first read path end to end.
  2. Outbox + reconnect/backoff — prove offline-first write path.
  3. 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.
  4. nylon-ring module boundary for StorageEngine — prove hot-swap works before generalizing the pattern to other subsystems.
  5. Media pipeline baseline (images, then video, then Lottie/mmap cache).
  6. Search (milli-fork), encryption tiers, trust manifest + launcher.
  7. UI Engine/Style/Feature axis split, HoF rendering layer maturation, pixel-1:1 passes.
  8. Predictor Tier 1, WASM plugin scaffolding, calls (tgcalls bindings).
  9. Predictor Tier 2/3, optional FFmpeg module, deferred items from OQ.md as they get resolved.