maarcadetweet: initial commit

AT Protocol PDS + AppView + Tauri Desktop Client, 160-char post limit.

- PDS (Rust + axum + sqlx)
  - Auth: createAccount, createSession, refreshSession
  - Records: createRecord, deleteRecord (race-safe via SELECT FOR UPDATE)
  - Feed: feed.like.create, feed.repost.create
  - Sync: getRepo, getBlocks, getLatestCommit, getRecord (with MST proof), listRepos
  - Identity: resolveHandle
  - MST: spec-conformant (at-mst crate, 27 tests)
  - Repo: signed commits, TID counter (monotonic, 4096 wrap safe)

- AppView (Rust + axum + sqlx)
  - Jetstream consumer (WebSocket, exponential backoff, 38k+ events indexed)
  - REST API: timeline/home (graph-aware), profile, search, post (with thread hydration)
  - Handle-sync worker (did:plc + did:web)
  - JSONB embed storage + thread columns (migration 0003)
  - Like/repost counter cache (migration 0004)

- Tauri 2 + Svelte 5 Desktop Client
  - System tray (Show/Compose/Quit menu)
  - OS notifications (tauri-plugin-notification)
  - Auto-update (tauri-plugin-updater, placeholder endpoint)
  - Window-state (tauri-plugin-window-state)
  - 160-char compose with live counter
  - Image/Link embed rendering
  - LocalStorage-persisted like state
  - Timeline with poll (prepend new posts)
  - Custom TitleBar (transparent, no decorations)
  - Orange/IBM Plex Mono maarcade design

Tests: 231 Rust + 9 vitest = 240 passed.
This commit is contained in:
tomdebone
2026-07-05 20:01:31 +02:00
commit c586fd39c9
134 changed files with 35279 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
use anyhow::Result;
use futures::{SinkExt, StreamExt};
use serde_json::json;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio_tungstenite::tungstenite::Message;
use tracing::{error, info, warn};
use crate::event::JetstreamEvent;
pub struct JetstreamConsumer {
pub url: String,
pub collections: Vec<String>,
pub max_backoff_secs: u64,
/// Optional handle the consumer toggles on every connect/disconnect.
/// Useful for health endpoints that want a live "are we connected?"
/// signal without polling.
pub connected: Option<Arc<AtomicBool>>,
/// Optional cursor (`time_us`) to resume from. 0 means "no cursor / start
/// fresh" which is Jetstream's default. Set via `with_cursor(...)`.
pub cursor_us: i64,
}
impl JetstreamConsumer {
pub fn new(url: impl Into<String>, collections: Vec<String>) -> Self {
Self {
url: url.into(),
collections,
max_backoff_secs: 30,
connected: None,
cursor_us: 0,
}
}
/// Build a consumer that shares a connection-state flag with the caller.
pub fn with_connected_flag(mut self, flag: Arc<AtomicBool>) -> Self {
self.connected = Some(flag);
self
}
/// Build a consumer with an explicit reconnect backoff ceiling (seconds).
pub fn with_max_backoff_secs(mut self, max: u64) -> Self {
self.max_backoff_secs = max.max(1);
self
}
/// Build a consumer that starts from a previously-persisted cursor
/// (microseconds since epoch). Setting this avoids the post-restart
/// gap where Jetstream's default backfill window might miss events.
pub fn with_cursor(mut self, cursor_us: i64) -> Self {
self.cursor_us = cursor_us;
self
}
pub async fn run<F, Fut>(&self, mut on_event: F) -> Result<()>
where
F: FnMut(JetstreamEvent) -> Fut + Send + 'static,
Fut: std::future::Future<Output = Result<()>> + Send,
{
let mut backoff_secs: u64 = 1;
loop {
let r = self.connect_and_consume(&mut on_event).await;
// Any non-error return (clean disconnect, error) means we lost
// the connection; flip the flag if we own one and back off.
if let Some(flag) = &self.connected {
flag.store(false, Ordering::Relaxed);
}
match r {
Ok(()) => warn!("jetstream stream ended, reconnecting"),
Err(e) => error!("jetstream error: {e:#}"),
}
warn!("reconnecting in {backoff_secs}s");
tokio::time::sleep(Duration::from_secs(backoff_secs)).await;
backoff_secs = (backoff_secs * 2).min(self.max_backoff_secs);
}
}
async fn connect_and_consume<F, Fut>(&self, on_event: &mut F) -> Result<()>
where
F: FnMut(JetstreamEvent) -> Fut + Send,
Fut: std::future::Future<Output = Result<()>> + Send,
{
let (mut ws, _) = tokio_tungstenite::connect_async(&self.url).await?;
info!("connected to jetstream: {}", self.url);
if let Some(flag) = &self.connected {
flag.store(true, Ordering::Relaxed);
}
if !self.collections.is_empty() || self.cursor_us > 0 {
let mut options = json!({ "type": "options" });
if !self.collections.is_empty() {
options["wantedCollections"] = json!(self.collections);
}
if self.cursor_us > 0 {
options["cursor"] = json!(self.cursor_us);
}
ws.send(Message::Text(options.to_string())).await?;
}
while let Some(msg) = ws.next().await {
let msg = msg?;
if let Message::Text(text) = msg {
if let Ok(ev) = serde_json::from_str::<JetstreamEvent>(&text) {
on_event(ev).await?;
}
}
}
Ok(())
}
}
+21
View File
@@ -0,0 +1,21 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JetstreamEvent {
pub did: String,
pub time_us: i64,
pub kind: String,
pub commit: Option<Value>,
pub identity: Option<Value>,
pub account: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommitOp {
pub action: String,
pub rkey: Option<String>,
pub path: Option<String>,
pub cid: Option<String>,
pub record: Option<Value>,
}
+5
View File
@@ -0,0 +1,5 @@
pub mod consumer;
pub mod event;
pub use consumer::JetstreamConsumer;
pub use event::{CommitOp, JetstreamEvent};