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 @@
|
||||
pub struct ApiPlaceholder;
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AuthSession {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub access_jwt: String,
|
||||
pub refresh_jwt: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Post {
|
||||
pub uri: String,
|
||||
pub cid: String,
|
||||
pub text: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn app_version() -> String {
|
||||
env!("CARGO_PKG_VERSION").to_string()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn status_pds() -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"rev": 1234,
|
||||
"lag_ms": 1200,
|
||||
"did": "did:plc:abc123def456ghi789jkl"
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn auth_login(handle: String, _password: String) -> Result<AuthSession, String> {
|
||||
Ok(AuthSession {
|
||||
did: "did:plc:placeholder".into(),
|
||||
handle,
|
||||
access_jwt: "stub.jwt.token".into(),
|
||||
refresh_jwt: "stub.refresh.jwt".into(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn post_create(text: String) -> Result<Post, String> {
|
||||
if text.is_empty() {
|
||||
return Err("text is empty".into());
|
||||
}
|
||||
Ok(Post {
|
||||
uri: "at://did:plc:placeholder/app.twi.post/3k2lmnop".into(),
|
||||
cid: "bafyreigd2j7tj4w3bvxgrm3l4xx7e2fnpwz5xv2eei6t7wzc4zqr4z".into(),
|
||||
text,
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn timeline_home(cursor: Option<String>) -> Result<serde_json::Value, String> {
|
||||
Ok(serde_json::json!({
|
||||
"posts": [],
|
||||
"cursor": cursor,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
pub mod api;
|
||||
pub mod appview_client;
|
||||
pub mod pds_client;
|
||||
pub mod state;
|
||||
pub mod store;
|
||||
|
||||
use appview_client::AppViewClient;
|
||||
use pds_client::PdsHttpClient;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use state::AppState;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AccountSession {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub access_jwt: String,
|
||||
pub refresh_jwt: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AppVersionResp {
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn app_version() -> AppVersionResp {
|
||||
AppVersionResp {
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn pds_describe(state: tauri::State<'_, AppState>) -> Result<serde_json::Value, String> {
|
||||
state.pds.describe_server().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn auth_register(
|
||||
state: tauri::State<'_, AppState>,
|
||||
handle: String,
|
||||
password: String,
|
||||
) -> Result<AccountSession, String> {
|
||||
let sess = state
|
||||
.pds
|
||||
.create_account(&handle, &password)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let s = AccountSession {
|
||||
did: sess.did.clone(),
|
||||
handle: sess.handle.clone(),
|
||||
access_jwt: sess.access_jwt.clone(),
|
||||
refresh_jwt: sess.refresh_jwt.clone(),
|
||||
};
|
||||
state.store.save(&s);
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn auth_login(
|
||||
state: tauri::State<'_, AppState>,
|
||||
identifier: String,
|
||||
password: String,
|
||||
) -> Result<AccountSession, String> {
|
||||
let sess = state
|
||||
.pds
|
||||
.create_session(&identifier, &password)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let s = AccountSession {
|
||||
did: sess.did.clone(),
|
||||
handle: sess.handle.clone(),
|
||||
access_jwt: sess.access_jwt.clone(),
|
||||
refresh_jwt: sess.refresh_jwt.clone(),
|
||||
};
|
||||
state.store.save(&s);
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn auth_refresh(state: tauri::State<'_, AppState>) -> Result<AccountSession, String> {
|
||||
let current = state
|
||||
.store
|
||||
.load()
|
||||
.ok_or_else(|| "no session".to_string())?;
|
||||
let sess = state
|
||||
.pds
|
||||
.refresh_session(¤t.refresh_jwt)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let s = AccountSession {
|
||||
did: sess.did.clone(),
|
||||
handle: sess.handle.clone(),
|
||||
access_jwt: sess.access_jwt.clone(),
|
||||
refresh_jwt: sess.refresh_jwt.clone(),
|
||||
};
|
||||
state.store.save(&s);
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn auth_logout(state: tauri::State<'_, AppState>) -> Result<(), String> {
|
||||
state.store.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn current_session(state: tauri::State<'_, AppState>) -> Result<Option<AccountSession>, String> {
|
||||
Ok(state.store.load())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn post_create(
|
||||
state: tauri::State<'_, AppState>,
|
||||
text: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let sess = state
|
||||
.store
|
||||
.load()
|
||||
.ok_or_else(|| "not logged in".to_string())?;
|
||||
let record = serde_json::json!({
|
||||
"text": text,
|
||||
"createdAt": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
let resp = state
|
||||
.pds
|
||||
.create_record(&sess.did, "app.twi.post", record, &sess.access_jwt)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(serde_json::json!({
|
||||
"uri": resp.uri,
|
||||
"cid": resp.cid,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn resolve_handle(
|
||||
state: tauri::State<'_, AppState>,
|
||||
handle: String,
|
||||
) -> Result<Option<String>, String> {
|
||||
state.pds.resolve_handle(&handle).await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Helper: split an `at://did/collection/rkey` URI into its
|
||||
/// `rkey` component. Returns an error string the Tauri command
|
||||
/// can surface directly to the Svelte frontend.
|
||||
fn rkey_from_uri(uri: &str) -> Result<String, String> {
|
||||
let rkey = uri
|
||||
.rsplit('/')
|
||||
.next()
|
||||
.ok_or_else(|| format!("invalid uri: {uri}"))?
|
||||
.to_string();
|
||||
if rkey.is_empty() {
|
||||
return Err(format!("invalid uri (empty rkey): {uri}"));
|
||||
}
|
||||
Ok(rkey)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn like_post(
|
||||
state: tauri::State<'_, AppState>,
|
||||
subject_uri: String,
|
||||
subject_cid: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let sess = state
|
||||
.store
|
||||
.load()
|
||||
.ok_or_else(|| "not logged in".to_string())?;
|
||||
let resp = state
|
||||
.pds
|
||||
.create_like(
|
||||
&sess.did,
|
||||
&subject_uri,
|
||||
&subject_cid,
|
||||
&sess.access_jwt,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(serde_json::json!({
|
||||
"uri": resp.uri,
|
||||
"cid": resp.cid,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn unlike_post(
|
||||
state: tauri::State<'_, AppState>,
|
||||
like_uri: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let sess = state
|
||||
.store
|
||||
.load()
|
||||
.ok_or_else(|| "not logged in".to_string())?;
|
||||
let rkey = rkey_from_uri(&like_uri)?;
|
||||
let resp = state
|
||||
.pds
|
||||
.delete_record(
|
||||
&sess.did,
|
||||
"app.bsky.feed.like",
|
||||
&rkey,
|
||||
&sess.access_jwt,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(serde_json::json!({
|
||||
"commit": resp.commit,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn repost_post(
|
||||
state: tauri::State<'_, AppState>,
|
||||
subject_uri: String,
|
||||
subject_cid: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let sess = state
|
||||
.store
|
||||
.load()
|
||||
.ok_or_else(|| "not logged in".to_string())?;
|
||||
// Reposts share the like wire shape — the PDS hardcodes the
|
||||
// collection, but `feed.like.create` is the only endpoint that
|
||||
// does so. For reposts we go through the generic
|
||||
// `com.atproto.repo.createRecord` with a repost-shaped record
|
||||
// value.
|
||||
let record = serde_json::json!({
|
||||
"subject": {
|
||||
"uri": subject_uri,
|
||||
"cid": subject_cid,
|
||||
},
|
||||
"createdAt": chrono::Utc::now().to_rfc3339(),
|
||||
});
|
||||
let resp = state
|
||||
.pds
|
||||
.create_record_with(&sess.did, "app.bsky.feed.repost", record, false, &sess.access_jwt)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(serde_json::json!({
|
||||
"uri": resp.uri,
|
||||
"cid": resp.cid,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn unrepost_post(
|
||||
state: tauri::State<'_, AppState>,
|
||||
repost_uri: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let sess = state
|
||||
.store
|
||||
.load()
|
||||
.ok_or_else(|| "not logged in".to_string())?;
|
||||
let rkey = rkey_from_uri(&repost_uri)?;
|
||||
let resp = state
|
||||
.pds
|
||||
.delete_record(
|
||||
&sess.did,
|
||||
"app.bsky.feed.repost",
|
||||
&rkey,
|
||||
&sess.access_jwt,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(serde_json::json!({
|
||||
"commit": resp.commit,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn timeline_home(
|
||||
state: tauri::State<'_, AppState>,
|
||||
did: String,
|
||||
cursor: Option<String>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<appview_client::TimelineResponse, String> {
|
||||
let lim = limit.unwrap_or(30).clamp(1, 100);
|
||||
state
|
||||
.appview
|
||||
.fetch_timeline(&did, cursor.as_deref(), lim)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn profile_get(
|
||||
state: tauri::State<'_, AppState>,
|
||||
handle: String,
|
||||
) -> Result<appview_client::ProfileResponse, String> {
|
||||
state
|
||||
.appview
|
||||
.fetch_profile(&handle)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn search(
|
||||
state: tauri::State<'_, AppState>,
|
||||
q: String,
|
||||
limit: Option<u32>,
|
||||
) -> Result<appview_client::SearchResponse, String> {
|
||||
let lim = limit.unwrap_or(30).clamp(1, 100);
|
||||
state
|
||||
.appview
|
||||
.fetch_search(&q, lim)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn post_get(
|
||||
state: tauri::State<'_, AppState>,
|
||||
uri: String,
|
||||
) -> Result<appview_client::ThreadResponse, String> {
|
||||
state
|
||||
.appview
|
||||
.fetch_post(&uri)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn status_pds(state: tauri::State<'_, AppState>) -> Result<serde_json::Value, String> {
|
||||
let sess = state.store.load();
|
||||
Ok(serde_json::json!({
|
||||
"did": sess.as_ref().map(|s| s.did.clone()),
|
||||
"handle": sess.as_ref().map(|s| s.handle.clone()),
|
||||
"authenticated": sess.is_some(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// `fetch_blob(did, cid)` — fetch raw blob bytes from the PDS for
|
||||
/// rendering image embeds. The Tauri shell does the HTTP call (rather
|
||||
/// than fetching via AppView) because blobs live on the user's PDS,
|
||||
/// not the AppView's indexer.
|
||||
#[tauri::command]
|
||||
async fn fetch_blob(
|
||||
state: tauri::State<'_, AppState>,
|
||||
did: String,
|
||||
cid: String,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
// Optionally pass the caller's JWT so authenticated PDSes (when
|
||||
// we enable that) receive the right token. Today `getBlob` is
|
||||
// unauthenticated so this is just `None`.
|
||||
let jwt = state.store.load().map(|s| s.access_jwt);
|
||||
state
|
||||
.pds
|
||||
.get_blob(&did, &cid, jwt.as_deref())
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Fire a native OS notification with an optional click target. The
|
||||
/// payload is also broadcast as an `app://notification` event so the
|
||||
/// frontend can route to `url` on click (via a notification listener
|
||||
/// registered in JS — see `frontend/src/main.ts`).
|
||||
///
|
||||
/// Used by the frontend for high-activity bursts (timeline event rate
|
||||
/// spike) and on first-session "new post by followed user" demo
|
||||
/// hooks. The Rust side emits the event up-front so the click target
|
||||
/// is in flight even if the OS strips the underlying notification
|
||||
/// (some platforms don't propagate click events back to Tauri).
|
||||
#[tauri::command]
|
||||
async fn show_notification(
|
||||
app: tauri::AppHandle,
|
||||
title: String,
|
||||
body: String,
|
||||
url: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
use tauri_plugin_notification::NotificationExt;
|
||||
|
||||
// Show the OS notification. The `.show()` call internally uses
|
||||
// `tauri::async_runtime::spawn` so it never blocks the caller.
|
||||
app.notification()
|
||||
.builder()
|
||||
.title(&title)
|
||||
.body(&body)
|
||||
.show()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Broadcast the metadata to the frontend so it can handle click
|
||||
// routing (focus window + navigate) without needing a per-action
|
||||
// action-type registration on the Rust side.
|
||||
let _ = tauri::Emitter::emit(
|
||||
&app,
|
||||
"app://notification",
|
||||
serde_json::json!({
|
||||
"title": title,
|
||||
"body": body,
|
||||
"url": url,
|
||||
}),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
|
||||
.init();
|
||||
|
||||
let pds_url = std::env::var("MAARCADETWEET_PDS_URL")
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:2583".to_string());
|
||||
let appview_url = std::env::var("MAARCADETWEET_APPVIEW_URL")
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:2584".to_string());
|
||||
|
||||
let state = AppState {
|
||||
pds: PdsHttpClient::new(pds_url),
|
||||
appview: AppViewClient::new(appview_url),
|
||||
store: store::SessionStore::new(),
|
||||
};
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_notification::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.manage(state)
|
||||
.setup(|app| {
|
||||
tracing::info!("maarcadetweet starting up");
|
||||
|
||||
// Embed the tray icon at compile time. `include_image!`
|
||||
// resolves paths relative to `CARGO_MANIFEST_DIR` and
|
||||
// bakes the raw RGBA pixels into the binary, so the
|
||||
// tray works regardless of the runtime CWD.
|
||||
const TRAY_ICON: tauri::image::Image<'static> =
|
||||
tauri::include_image!("icons/32x32.png");
|
||||
|
||||
let show_item = tauri::menu::MenuItem::with_id(
|
||||
app,
|
||||
"tray_show",
|
||||
"Show maarcadetweet",
|
||||
true,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| format!("failed to build show menu item: {e}"))?;
|
||||
let compose_item = tauri::menu::MenuItem::with_id(
|
||||
app,
|
||||
"tray_compose",
|
||||
"Compose",
|
||||
true,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| format!("failed to build compose menu item: {e}"))?;
|
||||
let quit_item = tauri::menu::MenuItem::with_id(
|
||||
app,
|
||||
"tray_quit",
|
||||
"Quit",
|
||||
true,
|
||||
None::<&str>,
|
||||
)
|
||||
.map_err(|e| format!("failed to build quit menu item: {e}"))?;
|
||||
let separator = tauri::menu::PredefinedMenuItem::separator(app)
|
||||
.map_err(|e| format!("failed to build separator: {e}"))?;
|
||||
|
||||
let tray_menu = tauri::menu::Menu::with_items(
|
||||
app,
|
||||
&[&show_item, &compose_item, &separator, &quit_item],
|
||||
)
|
||||
.map_err(|e| format!("failed to build tray menu: {e}"))?;
|
||||
|
||||
let _tray = tauri::tray::TrayIconBuilder::with_id("main-tray")
|
||||
.icon(TRAY_ICON)
|
||||
.icon_as_template(false)
|
||||
.tooltip("maarcadetweet")
|
||||
.menu(&tray_menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"tray_show" => {
|
||||
let _ = tauri::Emitter::emit(app, "app://show", ());
|
||||
}
|
||||
"tray_compose" => {
|
||||
let _ = tauri::Emitter::emit(app, "app://compose", ());
|
||||
}
|
||||
"tray_quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
// Left click on the tray icon brings the app forward;
|
||||
// right click is handled by the menu (see
|
||||
// `show_menu_on_left_click(false)`).
|
||||
if let tauri::tray::TrayIconEvent::Click {
|
||||
button: tauri::tray::MouseButton::Left,
|
||||
button_state: tauri::tray::MouseButtonState::Down,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
let _ = tauri::Emitter::emit(tray.app_handle(), "app://show", ());
|
||||
}
|
||||
})
|
||||
.build(app)
|
||||
.map_err(|e| format!("failed to build tray icon: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
app_version,
|
||||
pds_describe,
|
||||
auth_register,
|
||||
auth_login,
|
||||
auth_refresh,
|
||||
auth_logout,
|
||||
current_session,
|
||||
post_create,
|
||||
resolve_handle,
|
||||
timeline_home,
|
||||
profile_get,
|
||||
search,
|
||||
post_get,
|
||||
like_post,
|
||||
unlike_post,
|
||||
repost_post,
|
||||
unrepost_post,
|
||||
status_pds,
|
||||
fetch_blob,
|
||||
show_notification,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running maarcadetweet");
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
maarcadetweet_app_lib::run()
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub struct AppState {
|
||||
pub pds: crate::pds_client::PdsHttpClient,
|
||||
pub appview: crate::appview_client::AppViewClient,
|
||||
pub store: crate::store::SessionStore,
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
use crate::AccountSession;
|
||||
use parking_lot::Mutex;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SessionStore {
|
||||
inner: Arc<Mutex<Option<AccountSession>>>,
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
pub fn save(&self, sess: &AccountSession) {
|
||||
*self.inner.lock() = Some(sess.clone());
|
||||
}
|
||||
pub fn load(&self) -> Option<AccountSession> {
|
||||
self.inner.lock().clone()
|
||||
}
|
||||
pub fn clear(&self) {
|
||||
*self.inner.lock() = None;
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SessionStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user