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
@@ -0,0 +1,278 @@
//! Thin HTTP client the Tauri commands use to talk to the AppView.
//!
//! All four methods return parsed JSON or a stringified error that the
//! Tauri command layer surfaces to the Svelte frontend as the
//! `Result::Err` payload.
use anyhow::{anyhow, Context, Result};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::time::Duration;
/// One post as returned by the AppView read API.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PostDto {
pub uri: String,
pub did: String,
pub handle: String,
pub rkey: String,
pub collection: String,
pub text: String,
pub cid: String,
#[serde(default)]
pub parent_uri: Option<String>,
#[serde(default)]
pub root_uri: Option<String>,
#[serde(default)]
pub embed: Option<Value>,
#[serde(default)]
pub langs: Vec<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimelineResponse {
pub posts: Vec<PostDto>,
pub cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileResponse {
pub did: String,
pub handle: String,
pub posts: Vec<PostDto>,
pub followers: i64,
pub following: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResponse {
pub posts: Vec<PostDto>,
pub q: String,
}
/// `GET /api/post/{uri}` response. The server hands back the post plus
/// its `parent_uri` and `root_uri` rows in one round trip so the UI can
/// expand a thread without three sequential fetches.
///
/// `like_count` and `repost_count` are included when the server
/// resolves a real post; they're `None` for the "not in index"
/// sentinel response (where `post` is null). The AppView has no
/// auth yet, so we don't get `viewer_liked` / `viewer_reposted`
/// from the server.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThreadResponse {
pub post: Option<PostDto>,
pub thread: ThreadView,
#[serde(default)]
pub like_count: Option<i64>,
#[serde(default)]
pub repost_count: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThreadView {
pub parent: Option<PostDto>,
pub root: Option<PostDto>,
}
#[derive(Clone)]
pub struct AppViewClient {
pub base_url: String,
pub client: Client,
}
impl AppViewClient {
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
client: Client::builder()
.timeout(Duration::from_secs(10))
.build()
.unwrap(),
}
}
/// `GET /api/timeline/home?did=&limit=&cursor=`
pub async fn fetch_timeline(
&self,
did: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<TimelineResponse> {
let mut req = self
.client
.get(format!("{}/api/timeline/home", self.base_url))
.query(&[("did", did), ("limit", &limit.to_string())]);
if let Some(c) = cursor {
req = req.query(&[("cursor", c)]);
}
let resp = req
.send()
.await
.context("appview: failed to send timeline request")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!(
"appview: timeline home returned {}: {}",
status,
body
));
}
resp
.json::<TimelineResponse>()
.await
.context("appview: timeline home JSON parse")
}
/// `GET /api/profile/<handle>` — accepts `@handle` or `handle`, and
/// accepts a bare DID (the path param is opaque to the server).
/// For DIDs containing `:`, prefer [`Self::fetch_profile_by_did`]
/// which uses the query-param form.
pub async fn fetch_profile(&self, handle: &str) -> Result<ProfileResponse> {
let trimmed = handle.trim_start_matches('@');
// If it looks like a DID, prefer the query-param form so the
// colons don't have to be URL-encoded in the path.
if trimmed.starts_with("did:") {
return self.fetch_profile_by_did(trimmed).await;
}
let url = format!("{}/api/profile/{}", self.base_url, trimmed);
let resp = self
.client
.get(url)
.send()
.await
.context("appview: failed to send profile request")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!(
"appview: profile returned {}: {}",
status,
body
));
}
resp
.json::<ProfileResponse>()
.await
.context("appview: profile JSON parse")
}
/// `GET /api/profile?did=...` — safest way to fetch a profile by DID.
pub async fn fetch_profile_by_did(&self, did: &str) -> Result<ProfileResponse> {
let resp = self
.client
.get(format!("{}/api/profile", self.base_url))
.query(&[("did", did)])
.send()
.await
.context("appview: failed to send profile-by-did request")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!(
"appview: profile-by-did returned {}: {}",
status,
body
));
}
resp
.json::<ProfileResponse>()
.await
.context("appview: profile-by-did JSON parse")
}
/// `GET /api/search?q=&limit=`
pub async fn fetch_search(&self, q: &str, limit: u32) -> Result<SearchResponse> {
let resp = self
.client
.get(format!("{}/api/search", self.base_url))
.query(&[("q", q), ("limit", &limit.to_string())])
.send()
.await
.context("appview: failed to send search request")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!(
"appview: search returned {}: {}",
status,
body
));
}
resp
.json::<SearchResponse>()
.await
.context("appview: search JSON parse")
}
/// `GET /api/post/{uri}` — thread hydration in one round trip.
///
/// `uri` is the verbatim `at://...` URI. We can't paste it directly
/// into the path because the `://` looks like a scheme separator
/// to the URL parser; instead we percent-encode the whole URI and
/// append it as a single path segment. The server's
/// `axum::extract::Path<String>` decodes it back to the verbatim
/// string.
pub async fn fetch_post(&self, uri: &str) -> Result<ThreadResponse> {
let encoded = percent_encode_path(uri);
let resp = self
.client
.get(format!("{}/api/post/{}", self.base_url, encoded))
.send()
.await
.context("appview: failed to send post request")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!(
"appview: post returned {}: {}",
status,
body
));
}
resp
.json::<ThreadResponse>()
.await
.context("appview: post JSON parse")
}
}
/// Percent-encode every byte of `s` for use as a URL path segment.
/// `axum`'s path extractor will decode it back. We use this rather
/// than `url::Url::parse(...).path_segments()` because AT-Protocol
/// URIs contain `://` which the URL parser mistakes for a scheme.
fn percent_encode_path(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 3);
for b in s.bytes() {
// RFC 3986 unreserved characters plus a few safe ones we want
// to leave alone. Encode everything else to be conservative.
let is_unreserved = matches!(
b,
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~'
);
if is_unreserved {
out.push(b as char);
} else {
out.push_str(&format!("%{:02X}", b));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn percent_encode_path_at_uri() {
let s = "at://did:plc:abc/app.twi.post/3k2";
let e = percent_encode_path(s);
assert_eq!(
e,
"at%3A%2F%2Fdid%3Aplc%3Aabc%2Fapp.twi.post%2F3k2"
);
}
}