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:
@@ -0,0 +1,325 @@
|
||||
use anyhow::Result;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PdsHttpClient {
|
||||
pub base_url: String,
|
||||
pub client: Client,
|
||||
}
|
||||
|
||||
impl PdsHttpClient {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateAccountReq {
|
||||
pub handle: String,
|
||||
pub email: Option<String>,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AccountSession {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub access_jwt: String,
|
||||
pub refresh_jwt: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateRecordReq {
|
||||
pub repo: String,
|
||||
pub collection: String,
|
||||
pub record: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateRecordResp {
|
||||
pub uri: String,
|
||||
pub cid: String,
|
||||
}
|
||||
|
||||
/// `app.bsky.feed.like.create` request body — the flat BSky shape.
|
||||
/// The Tauri command always uses this shape (rather than the
|
||||
/// generic `createRecord` body) so the PDS can hardcode the
|
||||
/// collection.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateLikeBody {
|
||||
pub repo: String,
|
||||
pub subject: SubjectRef,
|
||||
#[serde(rename = "createdAt")]
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SubjectRef {
|
||||
pub uri: String,
|
||||
pub cid: String,
|
||||
}
|
||||
|
||||
/// `com.atproto.repo.deleteRecord` body. Reused for unlike and
|
||||
/// unrepost — the caller just sets `collection` to
|
||||
/// `app.bsky.feed.like` or `app.bsky.feed.repost`.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct DeleteRecordBody {
|
||||
pub repo: String,
|
||||
pub collection: String,
|
||||
pub rkey: String,
|
||||
}
|
||||
|
||||
/// Response from `feed.like.create` and (structurally) any
|
||||
/// `createRecord` variant. The PDS returns `{uri, cid, commit}`.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RepoWriteResp {
|
||||
pub uri: String,
|
||||
pub cid: String,
|
||||
#[serde(default)]
|
||||
pub commit: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Response from `com.atproto.repo.deleteRecord`. Spec returns
|
||||
/// `{ commit: { cid, rev } }`.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct DeleteRecordResp {
|
||||
pub commit: serde_json::Value,
|
||||
}
|
||||
|
||||
impl PdsHttpClient {
|
||||
pub async fn describe_server(&self) -> Result<serde_json::Value> {
|
||||
let r = self
|
||||
.client
|
||||
.get(format!("{}/xrpc/com.atproto.server.describeServer", self.base_url))
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
pub async fn create_account(
|
||||
&self,
|
||||
handle: &str,
|
||||
password: &str,
|
||||
) -> Result<AccountSession> {
|
||||
let body = CreateAccountReq {
|
||||
handle: handle.to_string(),
|
||||
email: None,
|
||||
password: password.to_string(),
|
||||
};
|
||||
let r = self
|
||||
.client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", self.base_url))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
if !r.status().is_success() {
|
||||
let status = r.status();
|
||||
let text = r.text().await.unwrap_or_default();
|
||||
anyhow::bail!("createAccount failed: {} {}", status, text);
|
||||
}
|
||||
Ok(r.json().await?)
|
||||
}
|
||||
|
||||
pub async fn create_session(
|
||||
&self,
|
||||
identifier: &str,
|
||||
password: &str,
|
||||
) -> Result<AccountSession> {
|
||||
let r = self
|
||||
.client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createSession", self.base_url))
|
||||
.json(&serde_json::json!({"identifier": identifier, "password": password}))
|
||||
.send()
|
||||
.await?;
|
||||
if !r.status().is_success() {
|
||||
let status = r.status();
|
||||
let text = r.text().await.unwrap_or_default();
|
||||
anyhow::bail!("createSession failed: {} {}", status, text);
|
||||
}
|
||||
Ok(r.json().await?)
|
||||
}
|
||||
|
||||
pub async fn refresh_session(&self, refresh_jwt: &str) -> Result<AccountSession> {
|
||||
let r = self
|
||||
.client
|
||||
.post(format!("{}/xrpc/com.atproto.server.refreshSession", self.base_url))
|
||||
.json(&serde_json::json!({"refresh_jwt": refresh_jwt}))
|
||||
.send()
|
||||
.await?;
|
||||
if !r.status().is_success() {
|
||||
let status = r.status();
|
||||
let text = r.text().await.unwrap_or_default();
|
||||
anyhow::bail!("refreshSession failed: {} {}", status, text);
|
||||
}
|
||||
Ok(r.json().await?)
|
||||
}
|
||||
|
||||
pub async fn create_record_with(
|
||||
&self,
|
||||
repo: &str,
|
||||
collection: &str,
|
||||
record: serde_json::Value,
|
||||
_validate: bool,
|
||||
jwt: &str,
|
||||
) -> Result<CreateRecordResp> {
|
||||
let r = self
|
||||
.client
|
||||
.post(format!("{}/xrpc/com.atproto.repo.createRecord", self.base_url))
|
||||
.bearer_auth(jwt)
|
||||
.json(&CreateRecordReq {
|
||||
repo: repo.to_string(),
|
||||
collection: collection.to_string(),
|
||||
record,
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
if !r.status().is_success() {
|
||||
let status = r.status();
|
||||
let text = r.text().await.unwrap_or_default();
|
||||
anyhow::bail!("createRecord failed: {} {}", status, text);
|
||||
}
|
||||
Ok(r.json().await?)
|
||||
}
|
||||
|
||||
pub async fn create_record(
|
||||
&self,
|
||||
repo: &str,
|
||||
collection: &str,
|
||||
record: serde_json::Value,
|
||||
jwt: &str,
|
||||
) -> Result<CreateRecordResp> {
|
||||
self.create_record_with(repo, collection, record, true, jwt).await
|
||||
}
|
||||
|
||||
pub async fn resolve_handle(&self, handle: &str) -> Result<Option<String>> {
|
||||
let r = self
|
||||
.client
|
||||
.post(format!("{}/xrpc/com.atproto.identity.resolveHandle", self.base_url))
|
||||
.json(&serde_json::json!({"handle": handle}))
|
||||
.send()
|
||||
.await?;
|
||||
if r.status().as_u16() == 404 {
|
||||
return Ok(None);
|
||||
}
|
||||
if !r.status().is_success() {
|
||||
anyhow::bail!("resolveHandle failed: {}", r.status());
|
||||
}
|
||||
let v: serde_json::Value = r.json().await?;
|
||||
Ok(v.get("did").and_then(|x| x.as_str()).map(String::from))
|
||||
}
|
||||
|
||||
/// `POST /xrpc/com.atproto.feed.like.create`
|
||||
///
|
||||
/// `repo` is the caller's DID; the JWT authenticates the call
|
||||
/// and must match. `subject` is the post being liked
|
||||
/// (`strongRef` = `{uri, cid}`).
|
||||
pub async fn create_like(
|
||||
&self,
|
||||
repo: &str,
|
||||
subject_uri: &str,
|
||||
subject_cid: &str,
|
||||
jwt: &str,
|
||||
) -> Result<RepoWriteResp> {
|
||||
let r = self
|
||||
.client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.feed.like.create",
|
||||
self.base_url
|
||||
))
|
||||
.bearer_auth(jwt)
|
||||
.json(&CreateLikeBody {
|
||||
repo: repo.to_string(),
|
||||
subject: SubjectRef {
|
||||
uri: subject_uri.to_string(),
|
||||
cid: subject_cid.to_string(),
|
||||
},
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
if !r.status().is_success() {
|
||||
let status = r.status();
|
||||
let text = r.text().await.unwrap_or_default();
|
||||
anyhow::bail!("feed.like.create failed: {} {}", status, text);
|
||||
}
|
||||
Ok(r.json().await?)
|
||||
}
|
||||
|
||||
/// `POST /xrpc/com.atproto.repo.deleteRecord`
|
||||
///
|
||||
/// Generic record deletion — `collection` is the NSID
|
||||
/// (`app.bsky.feed.like`, `app.bsky.feed.repost`, etc.) and
|
||||
/// `rkey` is the trailing component of the record's URI.
|
||||
pub async fn delete_record(
|
||||
&self,
|
||||
repo: &str,
|
||||
collection: &str,
|
||||
rkey: &str,
|
||||
jwt: &str,
|
||||
) -> Result<DeleteRecordResp> {
|
||||
let r = self
|
||||
.client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.repo.deleteRecord",
|
||||
self.base_url
|
||||
))
|
||||
.bearer_auth(jwt)
|
||||
.json(&DeleteRecordBody {
|
||||
repo: repo.to_string(),
|
||||
collection: collection.to_string(),
|
||||
rkey: rkey.to_string(),
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
if !r.status().is_success() {
|
||||
let status = r.status();
|
||||
let text = r.text().await.unwrap_or_default();
|
||||
anyhow::bail!("repo.deleteRecord failed: {} {}", status, text);
|
||||
}
|
||||
Ok(r.json().await?)
|
||||
}
|
||||
|
||||
/// `GET /xrpc/com.atproto.sync.getBlob?did=<did>&cid=<cid>`
|
||||
///
|
||||
/// Streams raw blob bytes from the user's PDS. `jwt` is currently
|
||||
/// unused — `com.atproto.sync.getBlob` is unauthenticated in this
|
||||
/// implementation, matching the other sync reads — but we keep
|
||||
/// the parameter in the signature so future auth-gated calls don't
|
||||
/// force a wire-format change.
|
||||
pub async fn get_blob(
|
||||
&self,
|
||||
did: &str,
|
||||
cid: &str,
|
||||
jwt: Option<&str>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let mut req = self.client.get(format!(
|
||||
"{}/xrpc/com.atproto.sync.getBlob",
|
||||
self.base_url
|
||||
));
|
||||
if let Some(t) = jwt {
|
||||
req = req.bearer_auth(t);
|
||||
}
|
||||
let resp = req
|
||||
.query(&[("did", did), ("cid", cid)])
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
// Try to capture the XRPC error body for easier debugging.
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("sync.getBlob failed: {} {}", status, body);
|
||||
}
|
||||
let bytes = resp.bytes().await?;
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user