Private messaging app built with Tauri v2 (Rust backend + vanilla JS frontend) on the Nostr protocol. Supports desktop (macOS, Windows, Linux) and Android.
Comments state the WHY of a non-obvious choice, in one or two lines max. They do NOT narrate the bug that led to the fix, the debugging session, the user's flow that surfaced it, audit/reviewer references, or which discovery sparked the change.
Anti-patterns (do not write these):
- "Sending a reply IS the read confirmation. updateChat's auto-mark is gated on focus, which can miss in race scenarios → user comes back to type a reply → auto-mark missed the receive. This catches the case where..."
- "(Reviewer ref: B1, B7.)"
- "Previously this pulled MY_SECRET_KEY.to_keys() directly — fine for local users, but for bunker accounts..."
- "Originally defined later alongside X; that came after Y so the catch saw
undefined..." - "Earlier versions did Z and ended up logging users in as device key..."
- Quoting test names, dates, or which testing pass surfaced the issue.
Good patterns:
- "Mark on own-send: updateChat's auto-mark is focus-gated."
- "GuardedKey vault — secret materialises in plaintext only for microseconds per op."
- "Multi-relay by design — single-relay connect URIs are a centralisation trap."
When in doubt: would this comment make sense to someone reading the code two years from now with no project context? If it requires knowing about a specific debugging episode, cut it. Default to no comment.
npm run dev # Desktop development (Tauri dev server)
npm run build # Desktop release build
npm run dev:bare # Dev without whisper feature (faster compile)
npm run build:bare # Release without whisper feature
npm run android:dev # Android dev (./scripts/android-dev.sh)
npm run android:build # Android release (tauri android build)Frontend build: node scripts/build-frontend.mjs copies src/ to dist/ with optional minification (terser + lightningcss in release).
Vector Core test suite: cd crates && cargo test -p vector-core.
All business logic lives here, fully decoupled from Tauri. Any client (GUI, CLI, SDK, bot) imports this crate.
macros.rs— log_info!, log_debug!, log_trace!, log_warn! (#[macro_export])types.rs— Message, Attachment, Reaction, EditEntry, ImageMetadata, SiteMetadataprofile/— Profile, ProfileFlags, SlimProfile (Box optimized, u16 interner handles)profile/sync.rs— ProfileSyncHandler trait, SyncPriority queue, load_profile, update_profile, update_status, block/unblock, nickname, background processor
chat.rs— Chat, ChatType, ChatMetadata, SerializableChatcompact.rs— CompactMessage (u64 ms timestamps), CompactMessageVec, NpubInterner, TinyVec, bitflagsstate.rs— ChatState, all globals (NOSTR_CLIENT, MY_SECRET_KEY, STATE, etc.), WrapperIdCache, processing gatecrypto/— GuardedKey vault, GuardedSigner, Argon2id, AES-GCM, ChaCha20, decrypt_data, extension_from_mime, sanitize_filename, resolve_unique_filename, format_bytes, mime_from_magic_bytes, mime_from_extension (full MIME map)db/— SQLite schema, 20 atomic migrations, connection pools, RAII guards, settings KVhex.rs— SIMD hex encode/decode (NEON ARM64, SSE2/AVX2 x86_64, scalar fallback)rumor.rs— process_rumor() inbound message parser, RumorEvent, 11 result variantsstored_event.rs— StoredEvent, StoredEventBuilder, event_kind constantssending.rs— SendCallback trait, SendConfig, send_dm/send_file_dm/send_rumor_dm, retry_send_gift_wrapblossom.rs— File upload with progress tracking, retry, server failoverinbox_relays.rs— NIP-17 kind 10050 relay resolution, stampede-protected cache, gift-wrap sendingnet.rs— SSRF protection, build_http_clientstats.rs— CacheStats, DeepSize trait for memory benchmarking (debug builds)traits.rs— EventEmitter trait (abstracts UI notification), ProgressReporter
src-tauri consumes vector-core via path = "../crates/vector-core". Types and globals are re-exported — same instances, shared memory.
lib.rs— App entry, plugin registration,invoke_handlerwith 150+ commandscommands/— Tauri command handlers (thin wrappers around vector-core logic)state/— Re-exports vector-core globals + local TAURI_APP + TauriEventEmitter (bridges emit_event to Tauri)macros.rs— log_error! only (toast + log file via TAURI_APP; log_info/debug/trace/warn in vector-core)rumor.rs— Thin wrapper: re-exports vector-core + parse_mls_imeta_attachments + process_rumor_with_mls + resolve_download_dirmessage/— Re-exports vector-core types + TauriSendCallback + file dedup logicservices/— Event handler, subscription handler, notificationsmls/— MLS group encryption via OpenMLS/MDK (not yet in vector-core)miniapps/— WebXDC-compatible mini apps (Tauri-specific: custom protocol, WebView, Iroh P2P)android/— JNI bindings, localhost media server, background syncsimd/— SIMD image, audio, URL, HTML operations (hex moved to vector-core)
main.js— Main application logic (~25k lines, bundled)js/— ES modules: chat-scroll, emoji, file-preview, marketplace, settings, voice, db, platforms/styles.css— All styles (~7k lines)index.html— Single-page app shell
Frontend communicates with backend via window.__TAURI__.core.invoke().
🚨 Multi-account session safety — read this BEFORE writing any code that touches STATE, DB, or relays
Vector supports N accounts per install. A swap_session / reset_session can happen at any await point: the user might switch accounts mid-fetch, mid-publish, mid-MLS-sync, mid-anything. When that happens:
STATE(chats/profiles) is replaced with the new account's data- The DB pool (
POOL_GENERATION) is swapped to the new account's vector.db MY_KEYS/MY_PUBLIC_KEY/ENCRYPTION_KEYare rebound- The per-account marker file points at the new npub
Any task still running with values captured before the swap will write account A's data into account B's storage. This has caused multiple real bugs: MLS messages from the previous account appearing in a fresh account's chat list, profile updates persisting to the wrong DB, kind-10063 server lists merging across accounts. The damage is invisible until the user opens the wrong chat.
Use vector_core::state::SessionGuard to defend against this:
let session = SessionGuard::capture(); // snapshot generation NOW
// ...network/file/long-await work...
if !session.is_valid() { return; } // bail if the generation advanced
// ...STATE/DB mutation...Rules — apply every single one of these:
- Every
tokio::spawnthat touches per-account state needs a capturedSessionGuardBEFORE the spawn boundary and anis_valid()check before its first side effect. Capturing inside theasync moveblock is too late — the spawn order is unobserved. - Every long async function (≥ ~1s, anything network-bound) that ends in a write needs a re-check before that write, even if the caller already validated. Fetches can take seconds; the validation must straddle the I/O.
- Every Tauri command that mutates per-account settings/DB needs a guard at entry. Pattern: capture
SessionGuard, do the read/mutate/save sandwich, re-checkis_valid()immediately beforesave_*. Don't trustget_current_account()alone — it returns the current account, which may not be the one the caller expects. - Per-group locks and account-scoped service instances (
MlsService, etc.) freeze their per-account paths at construction. A stale instance keeps decrypting account A's MLS storage successfully and writes the plaintext into account B's STATE. Gate every method that mutates state on aSessionGuardcaptured at the call site, not at construction. - The debounced republish pattern (
republish_*_debounced) capturesSessionGuardbefore the sleep. Copy that pattern for any debounced effect.
tokio::spawn(without aSessionGuard::capture()on the lines just before itclient.fetch_events,client.send_event_builder,tokio::time::sleepbetween two writes to STATE/DB (without anis_valid()between fetch and save)static/OnceLock/LazyLockstoring anything per-account that doesn't refresh on swap- A function that takes
&Clientplus annpub/PublicKeyargument and writes to STATE or per-account DB — almost certainly needs aSessionGuardparameter too - New tables / settings keys created without
account_dir(npub)scoping - Anything pre-fetched into a
Vec<String>before aforloop that does network/DB writes — the loop must re-check session each iteration
crates/vector-core/src/inbox_relays.rs::republish_inbox_relays_debounced— debounced publish with SessionGuardcrates/vector-core/src/blossom_servers.rs::fetch_and_merge_own_list— long fetch + write, takesSessionGuardparameter, re-checks before save AND before cache refreshcrates/vector-core/src/mls/service.rs::sync_group_since_cursor— SessionGuard captured at entry, re-validated before each per-rumor STATE writesrc-tauri/src/commands/relays.rs::require_active_blossom_session— entry-guard helper for mutation commands
When in doubt, add a guard. Cost: one atomic load. Cost of the bug it prevents: catastrophic cross-account data corruption.
Every new #[tauri::command] requires THREE things:
- Permission TOML in
src-tauri/permissions/autogenerated/<command_name>.toml(createallow-anddeny-entries) "allow-<command-name-with-hyphens>"added tosrc-tauri/capabilities/default.json- Registration in the
invoke_handlermacro inlib.rs
Missing any = invoke() silently rejects with "Command X not allowed by ACL".
If the command mutates per-account state, also see the multi-account section above — capture SessionGuard at entry, re-validate before any save_* call.
All DM sends (text + file) flow through vector-core's send_dm/send_file_dm/send_rumor_dm:
SendCallbacktrait — 7 lifecycle hooks (on_pending, on_sent, on_failed, on_upload_progress, on_upload_complete, on_attachment_preview, on_persist) with default no-opsSendConfig— per-call config: max_send_attempts, retry_delay, self_send, cancel_token. Presets:gui()(12 retries),headless()(3),default()(1)TauriSendCallback— emits to JS frontend + DB persistenceCliSendCallback— terminal output for sent/failed/progress- Text DMs:
message()short-circuits tovector_core::send_dmwithTauriSendCallback - File DMs: src-tauri handles dedup + upload, then calls
vector_core::send_rumor_dmfor gift-wrap + retry - MLS groups: stay in src-tauri (MDK dependency)
All profile operations (fetch, publish, block, nickname) flow through vector-core's profile::sync module:
ProfileSyncHandlertrait —on_profile_fetched(slim, avatar_url, banner_url)with default no-op. Covers DB persistence + image caching.TauriProfileSyncHandler— spawnsdb::set_profile+cache_profile_imagesEventEmittertrait — abstracts UI notification.TauriEventEmitterbridges toTAURI_APP.emit(), registered at startup.- Profile ops in vector-core:
load_profile,update_profile,update_status,block_user,unblock_user,set_nickname,get_blocked_users - Sync queue:
SyncPriority(Critical/High/Medium/Low),ProfileSyncQueue,start_profile_sync_processor - src-tauri profile commands are one-line delegates to vector-core
Global state lives in src-tauri/src/state/ and is re-exported at crate root:
TAURI_APP,NOSTR_CLIENT,MY_KEYS,MY_PUBLIC_KEY,STATESTATEholdsArc<Mutex<AppState>>with chats, profiles, settings- Multi-account: separate SQLite DB per account in
~/.local/share/io.vectorapp/data/<npub>/
All commands return Result<T, String>. Errors are string-formatted for frontend display.
- WebView
shouldInterceptRequestthreads have NO tokio runtime —Handle::current()will PANIC. Usetry_lock()with retry loops for STATE access from JNI threads. - Localhost media server (
android/media_server.rs) serves files becauseasset://doesn't support Range requests for audio/video. - rustls must use
ringprovider (notaws-lc-rs) — currently satisfied naturally (no aws-lc in the lock); re-verify if a new dependency pulls rustls with default providers.
message/compact.rs defines CompactMessage / CompactMessageVec — a memory-optimized format using Box<str>, u16 npub interning, and [u8; 32] IDs instead of hex strings. Messages are stored in compact form in memory and converted to full Message structs for frontend serialization.
Files are encrypted (NIP-96/Blossom), uploaded to media servers, and referenced via SHA-256 hash. The name field carries the original filename through the protocol. Downloads save with human-readable names + collision suffixes (-1, -2). Hash-based dedup prevents re-downloading identical content.
Key crates: nostr-sdk 0.44, tauri 2.10, tokio 1.49, rusqlite 0.32, openmls, iroh 0.96, iroh-gossip 0.96, aes-gcm, argon2, image 0.25
Local path deps: ../../mdk/crates/mdk-* (MDK media/encryption library)
wry fork: [patch.crates-io] in Cargo.toml points to local ../../wry for WKWebView background color fix.
- macOS: WKWebView white flash prevented via
drawsBackgroundKVC on config (wry fork). Metal GPU for Whisper. - Linux:
WEBKIT_DISABLE_DMABUF_RENDERER=1set for WebKitGTK compatibility. - Android: API 26+. Vulkan GPU disabled for Whisper (device freeze). OpenSSL vendored.
- Feature flag:
whisper(default) — enables OpenAI Whisper transcription. Use--no-default-featuresto skip.