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.
279 lines
8.8 KiB
Rust
279 lines
8.8 KiB
Rust
//! 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"
|
|
);
|
|
}
|
|
}
|