Die vier viewer-bezogenen AppView-Aufrufe (Timeline, Notifications, Count, Seen) senden jetzt das Access-JWT. Ohne Session gibt es einen sprechenden Fehler statt eines leeren Bearer-Headers. Dabei kam heraus, dass die automatische Token-Erneuerung noch nie funktioniert hat: isTokenInvalid() stieg mit `typeof e !== "object"` sofort aus, aber Tauri lehnt bei Commands mit Result<T, String> mit einem blanken String ab — der Zweig war seit seiner Einführung tot. Belegt per Mutationstest: mit der alten Zeile fallen acht der neuen Tests um. Die Prüfung liest den Fehlertext jetzt über einen Helfer, der Strings und Objekte behandelt. Dazu: der Badge-Poll bricht ab, wenn die Erneuerung endgültig scheitert, statt weiter gegen einen 401 zu laufen. 503 AuthUnavailable gilt dabei bewusst nicht als Auth-Fehler — die PDS kann kurz weg sein, der Poll soll das überdauern. Co-Authored-By: Eifelduds <noreply@eifelduds.de> Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
1054 lines
33 KiB
Rust
1054 lines
33 KiB
Rust
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,
|
|
embed: Option<serde_json::Value>,
|
|
reply: Option<pds_client::ReplyRef>,
|
|
) -> Result<serde_json::Value, String> {
|
|
let sess = state
|
|
.store
|
|
.load()
|
|
.ok_or_else(|| "not logged in".to_string())?;
|
|
let mut record = serde_json::json!({
|
|
"text": text,
|
|
"createdAt": chrono::Utc::now().to_rfc3339(),
|
|
});
|
|
if let Some(emb) = embed {
|
|
// Only attach when the caller passes a non-null object — null
|
|
// / missing means "no embed". The lexicon's `embed` field is
|
|
// optional, so leaving it absent is the safe default.
|
|
if !emb.is_null() {
|
|
record["embed"] = emb;
|
|
}
|
|
}
|
|
// The `reply` field on a post record (see
|
|
// `app.bsky.feed.post`) is `{root, parent}` strongRefs. We only
|
|
// attach it when the caller passes a non-null object; missing
|
|
// means "top-level post" which is the default.
|
|
if let Some(rp) = reply {
|
|
record["reply"] = serde_json::json!({
|
|
"root": { "uri": rp.root.uri, "cid": rp.root.cid },
|
|
"parent": { "uri": rp.parent.uri, "cid": rp.parent.cid },
|
|
});
|
|
}
|
|
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,
|
|
}))
|
|
}
|
|
|
|
/// `follow_user(target_did)` — create an `app.bsky.graph.follow`
|
|
/// record on the user's PDS pointing at `target_did`. Returns the
|
|
/// new record's URI (the client caches this in localStorage so it
|
|
/// can be deleted by `unfollow_user` without an extra round-trip).
|
|
///
|
|
/// `subject` in the follow record is just a DID string, not a
|
|
/// strong-ref — the PDS is the source of truth for which follow
|
|
/// record belongs to which subject.
|
|
#[tauri::command]
|
|
async fn follow_user(
|
|
state: tauri::State<'_, AppState>,
|
|
target_did: String,
|
|
) -> Result<serde_json::Value, String> {
|
|
let sess = state
|
|
.store
|
|
.load()
|
|
.ok_or_else(|| "not logged in".to_string())?;
|
|
if target_did == sess.did {
|
|
return Err("can't follow yourself".into());
|
|
}
|
|
let record = serde_json::json!({
|
|
"$type": "app.bsky.graph.follow",
|
|
"subject": target_did,
|
|
"createdAt": chrono::Utc::now().to_rfc3339(),
|
|
});
|
|
let resp = state
|
|
.pds
|
|
.create_record(
|
|
&sess.did,
|
|
"app.bsky.graph.follow",
|
|
record,
|
|
&sess.access_jwt,
|
|
)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(serde_json::json!({
|
|
"uri": resp.uri,
|
|
"cid": resp.cid,
|
|
}))
|
|
}
|
|
|
|
/// `unfollow_user(follow_uri)` — delete the previously-created
|
|
/// follow record. The client passes the cached URI from its
|
|
/// `localStorage` so we don't need a separate "list my follows"
|
|
/// endpoint to find the right rkey.
|
|
#[tauri::command]
|
|
async fn unfollow_user(
|
|
state: tauri::State<'_, AppState>,
|
|
follow_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(&follow_uri)?;
|
|
let resp = state
|
|
.pds
|
|
.delete_record(
|
|
&sess.did,
|
|
"app.bsky.graph.follow",
|
|
&rkey,
|
|
&sess.access_jwt,
|
|
)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(serde_json::json!({
|
|
"commit": resp.commit,
|
|
}))
|
|
}
|
|
|
|
/// Access JWT for the AppView's viewer-scoped endpoints.
|
|
///
|
|
/// The four authenticated AppView calls (`timeline_home`,
|
|
/// `fetch_notifications`, `notification_count`,
|
|
/// `mark_notifications_seen`) all need the same thing: the stored
|
|
/// session's access JWT, or a message the UI can render when there
|
|
/// isn't one. Factored out so no call site can accidentally send an
|
|
/// empty `Authorization: Bearer` header — which the AppView would
|
|
/// answer with a 401 `TokenInvalid`, and the frontend would then burn a
|
|
/// pointless refresh round trip on before failing anyway.
|
|
///
|
|
/// The message deliberately contains neither `TokenInvalid` nor
|
|
/// `ExpiredSignature`: `safeInvoke`'s `isTokenInvalid()` greps for those
|
|
/// substrings, and a logged-out client has nothing to refresh *with*.
|
|
fn require_access_jwt(state: &AppState, what: &str) -> Result<String, String> {
|
|
state
|
|
.store
|
|
.load()
|
|
.map(|s| s.access_jwt)
|
|
.ok_or_else(|| format!("not logged in: {what} requires a signed-in session"))
|
|
}
|
|
|
|
#[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);
|
|
let jwt = require_access_jwt(&state, "the home timeline")?;
|
|
state
|
|
.appview
|
|
.fetch_timeline(&did, cursor.as_deref(), lim, &jwt)
|
|
.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())
|
|
}
|
|
|
|
/// `profile_get_by_did(did)` — resolve a profile by DID rather than
|
|
/// by handle (`GET /api/profile?did=…`).
|
|
///
|
|
/// This is the *only* correct lookup for an actor that came out of a
|
|
/// notification or a follower list: the AppView synthesises a
|
|
/// placeholder `handle` (a truncated DID, see `short_did_bare`) for
|
|
/// actors it has neither a `profiles` row nor an indexed post for, and
|
|
/// feeding that placeholder back into the handle lookup matches
|
|
/// nothing — `resolve_profile` then answers with a synthetic empty
|
|
/// profile. The DID in the notification / actor DTO is real, so we
|
|
/// navigate by that instead.
|
|
#[tauri::command]
|
|
async fn profile_get_by_did(
|
|
state: tauri::State<'_, AppState>,
|
|
did: String,
|
|
) -> Result<appview_client::ProfileResponse, String> {
|
|
state
|
|
.appview
|
|
.fetch_profile_by_did(&did)
|
|
.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())
|
|
}
|
|
|
|
/// `fetch_thread(uri, viewer_did?)` — the full thread around `uri`:
|
|
/// the whole ancestor chain (root first) plus the direct replies
|
|
/// (oldest first). `post_get` stays the narrower two-hop shape the
|
|
/// timeline's inline thread modal uses; this is what a dedicated
|
|
/// thread view wants.
|
|
#[tauri::command]
|
|
async fn fetch_thread(
|
|
state: tauri::State<'_, AppState>,
|
|
uri: String,
|
|
viewer_did: Option<String>,
|
|
) -> Result<appview_client::ThreadFullResponse, String> {
|
|
state
|
|
.appview
|
|
.fetch_thread(&uri, viewer_did.as_deref())
|
|
.await
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// `fetch_notifications(did, cursor?, limit?)` — one page of the
|
|
/// recipient's notifications, newest first. Same limit clamp as
|
|
/// `timeline_home` (the server clamps too, but doing it here means a
|
|
/// bad `limit` never costs a round trip).
|
|
#[tauri::command]
|
|
async fn fetch_notifications(
|
|
state: tauri::State<'_, AppState>,
|
|
did: String,
|
|
cursor: Option<String>,
|
|
limit: Option<u32>,
|
|
) -> Result<appview_client::NotificationsResponse, String> {
|
|
let lim = limit.unwrap_or(30).clamp(1, 100);
|
|
let jwt = require_access_jwt(&state, "notifications")?;
|
|
state
|
|
.appview
|
|
.fetch_notifications(&did, cursor.as_deref(), lim, &jwt)
|
|
.await
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// `notification_count(did)` — unread count for the NavRail badge.
|
|
#[tauri::command]
|
|
async fn notification_count(
|
|
state: tauri::State<'_, AppState>,
|
|
did: String,
|
|
) -> Result<appview_client::NotificationCountResponse, String> {
|
|
let jwt = require_access_jwt(&state, "the unread-notification count")?;
|
|
state
|
|
.appview
|
|
.notification_count(&did, &jwt)
|
|
.await
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// `mark_notifications_seen(did, seen_at?)` — mark every notification
|
|
/// indexed at or before `seen_at` as read. The frontend passes the
|
|
/// `indexed_at` of the topmost row it actually rendered, so a
|
|
/// notification that lands mid-scroll is never silently swallowed.
|
|
/// Omitting `seen_at` marks everything currently unread.
|
|
#[tauri::command]
|
|
async fn mark_notifications_seen(
|
|
state: tauri::State<'_, AppState>,
|
|
did: String,
|
|
seen_at: Option<String>,
|
|
) -> Result<appview_client::NotificationSeenResponse, String> {
|
|
let jwt = require_access_jwt(&state, "marking notifications seen")?;
|
|
state
|
|
.appview
|
|
.mark_notifications_seen(&did, seen_at.as_deref(), &jwt)
|
|
.await
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// `fetch_followers(did, cursor?, limit?)` — one page of the actors
|
|
/// who follow `did`.
|
|
#[tauri::command]
|
|
async fn fetch_followers(
|
|
state: tauri::State<'_, AppState>,
|
|
did: String,
|
|
cursor: Option<String>,
|
|
limit: Option<u32>,
|
|
) -> Result<appview_client::ActorListResponse, String> {
|
|
let lim = limit.unwrap_or(30).clamp(1, 100);
|
|
state
|
|
.appview
|
|
.fetch_followers(&did, cursor.as_deref(), lim)
|
|
.await
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// `fetch_following(did, cursor?, limit?)` — one page of the actors
|
|
/// `did` follows.
|
|
#[tauri::command]
|
|
async fn fetch_following(
|
|
state: tauri::State<'_, AppState>,
|
|
did: String,
|
|
cursor: Option<String>,
|
|
limit: Option<u32>,
|
|
) -> Result<appview_client::ActorListResponse, String> {
|
|
let lim = limit.unwrap_or(30).clamp(1, 100);
|
|
state
|
|
.appview
|
|
.fetch_following(&did, cursor.as_deref(), lim)
|
|
.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())
|
|
}
|
|
|
|
/// `pick_and_upload_image()` — open a native file picker, read the
|
|
/// chosen file, and upload its bytes to the user's PDS via
|
|
/// `com.atproto.uploadBlob`.
|
|
///
|
|
/// Returns the parsed `com.atproto.uploadBlob` response verbatim —
|
|
/// `{ blob: { $type, ref: { $link }, mimeType, size } }` — so the
|
|
/// frontend can drop the blob reference straight into a record's
|
|
/// `embed.images[].image` field. Returns `None` when the user
|
|
/// cancels the dialog.
|
|
///
|
|
/// MIME-type resolution:
|
|
/// 1. Sniff the file extension (`.png` → `image/png`, `.jpg`/`.jpeg`
|
|
/// → `image/jpeg`, `.gif` → `image/gif`, `.webp` → `image/webp`).
|
|
/// 2. If the extension is unknown, fall back to
|
|
/// `application/octet-stream`. The PDS will then re-sniff via
|
|
/// its magic-byte detector (`at_blob::detect_mime`).
|
|
///
|
|
/// Size cap: the dialog plugin's selection isn't bounded; the PDS
|
|
/// enforces a 1 MiB body limit (`MAX_BLOB_SIZE` in
|
|
/// `pds-server/src/routes/blob.rs`) and returns 413 if exceeded.
|
|
/// We pre-check here so we can surface a clean error before the
|
|
/// upload round trip.
|
|
#[tauri::command]
|
|
async fn pick_and_upload_image(
|
|
app: tauri::AppHandle,
|
|
state: tauri::State<'_, AppState>,
|
|
) -> Result<Option<pds_client::UploadBlobResp>, String> {
|
|
use tauri_plugin_dialog::DialogExt;
|
|
|
|
// Confirm we have a session — uploading without auth is a no-op
|
|
// on the server side (401), but we'd rather tell the caller
|
|
// upfront than surface a confusing PDS error.
|
|
let sess = state
|
|
.store
|
|
.load()
|
|
.ok_or_else(|| "not logged in".to_string())?;
|
|
|
|
// `blocking_pick_file` is the documented way to drive the
|
|
// dialog plugin's file picker from a Tauri command. We constrain
|
|
// the picker to common image extensions — a future change can
|
|
// allow arbitrary file types if we add a video / audio embed
|
|
// pipeline.
|
|
let picked = app
|
|
.dialog()
|
|
.file()
|
|
.add_filter("Image", &["png", "jpg", "jpeg", "gif", "webp"])
|
|
.set_title("Attach image")
|
|
.blocking_pick_file();
|
|
|
|
let Some(file_path) = picked else {
|
|
return Ok(None);
|
|
};
|
|
|
|
// The dialog plugin returns a `FilePath` enum (Path | Url). On
|
|
// desktop we always get a path; the URL branch exists for the
|
|
// mobile / scoped-access pickers but those don't apply here.
|
|
let path = match file_path.into_path() {
|
|
Ok(p) => p,
|
|
Err(e) => return Err(format!("invalid picked file: {e}")),
|
|
};
|
|
|
|
let bytes = tokio::fs::read(&path)
|
|
.await
|
|
.map_err(|e| format!("read {}: {e}", path.display()))?;
|
|
|
|
if bytes.is_empty() {
|
|
return Err("picked file is empty".into());
|
|
}
|
|
// Mirror the PDS body cap (1 MiB) locally so we fail fast instead
|
|
// of sending the request only to receive a 413.
|
|
const MAX_BLOB_SIZE: usize = 1024 * 1024;
|
|
if bytes.len() > MAX_BLOB_SIZE {
|
|
return Err(format!(
|
|
"file is {} bytes; max is {MAX_BLOB_SIZE}",
|
|
bytes.len()
|
|
));
|
|
}
|
|
|
|
let mime = mime_from_extension(&path);
|
|
let resp = state
|
|
.pds
|
|
.upload_blob(bytes, &mime, &sess.access_jwt)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(Some(resp))
|
|
}
|
|
|
|
/// Open an external URL in the user's default browser. Returns
|
|
/// silently on success. Only accepts `http://` and `https://` —
|
|
/// any other scheme (e.g. `file://`, `mailto:`, `javascript:`)
|
|
/// is rejected so the Rust side is the single source of truth for
|
|
/// which schemes are allowed.
|
|
#[tauri::command]
|
|
async fn open_external_url(
|
|
url: String,
|
|
app: tauri::AppHandle,
|
|
) -> Result<(), String> {
|
|
use tauri_plugin_shell::ShellExt;
|
|
let lower = url.to_ascii_lowercase();
|
|
if !(lower.starts_with("http://") || lower.starts_with("https://")) {
|
|
return Err(format!(
|
|
"open_external_url: refusing non-http(s) scheme in {url:?}"
|
|
));
|
|
}
|
|
app.shell()
|
|
.open(url, None)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Map a file extension to a MIME type. Returns
|
|
/// `application/octet-stream` when the extension is unrecognised —
|
|
/// the PDS's magic-byte sniffer will take over from there.
|
|
fn mime_from_extension(path: &std::path::Path) -> String {
|
|
let ext = path
|
|
.extension()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or("")
|
|
.to_ascii_lowercase();
|
|
match ext.as_str() {
|
|
"png" => "image/png",
|
|
"jpg" | "jpeg" => "image/jpeg",
|
|
"gif" => "image/gif",
|
|
"webp" => "image/webp",
|
|
// The PDS still accepts the upload and stores the bytes; the
|
|
// sniffed MIME type from magic-byte detection will fill in
|
|
// `mime_type` server-side on the next getBlob.
|
|
_ => "application/octet-stream",
|
|
}
|
|
.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.clone()),
|
|
appview: AppViewClient::new(appview_url.clone()),
|
|
appview_url,
|
|
store: store::SessionStore::new(),
|
|
};
|
|
|
|
tauri::Builder::default()
|
|
.plugin(tauri_plugin_notification::init())
|
|
.plugin(tauri_plugin_dialog::init())
|
|
.plugin(tauri_plugin_shell::init())
|
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
|
.plugin(tauri_plugin_window_state::Builder::default().build())
|
|
.manage(state)
|
|
.setup(|app| {
|
|
use tauri::Manager;
|
|
tracing::info!("maarcadetweet starting up");
|
|
|
|
// Open the webview devtools on startup in debug builds so
|
|
// we can see the console + DOM inspector without reaching
|
|
// for the macOS menu. The production build has no debug
|
|
// assertions so this branch is a no-op there.
|
|
#[cfg(debug_assertions)]
|
|
if let Some(win) = app.get_webview_window("main") {
|
|
let _ = win.open_devtools();
|
|
}
|
|
|
|
// 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 home_item = tauri::menu::MenuItem::with_id(
|
|
app,
|
|
"tray_home",
|
|
"Home",
|
|
true,
|
|
None::<&str>,
|
|
)
|
|
.map_err(|e| format!("failed to build home 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 profile_item = tauri::menu::MenuItem::with_id(
|
|
app,
|
|
"tray_profile",
|
|
"Profile",
|
|
true,
|
|
None::<&str>,
|
|
)
|
|
.map_err(|e| format!("failed to build profile menu item: {e}"))?;
|
|
let search_item = tauri::menu::MenuItem::with_id(
|
|
app,
|
|
"tray_search",
|
|
"Search",
|
|
true,
|
|
None::<&str>,
|
|
)
|
|
.map_err(|e| format!("failed to build search menu item: {e}"))?;
|
|
let settings_item = tauri::menu::MenuItem::with_id(
|
|
app,
|
|
"tray_settings",
|
|
"Settings",
|
|
true,
|
|
None::<&str>,
|
|
)
|
|
.map_err(|e| format!("failed to build settings 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,
|
|
&home_item,
|
|
&compose_item,
|
|
&profile_item,
|
|
&search_item,
|
|
&settings_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_home" => {
|
|
let _ = tauri::Emitter::emit(app, "app://navigate", "home");
|
|
}
|
|
"tray_compose" => {
|
|
let _ = tauri::Emitter::emit(app, "app://compose", ());
|
|
}
|
|
"tray_profile" => {
|
|
let _ = tauri::Emitter::emit(app, "app://navigate", "profile");
|
|
}
|
|
"tray_search" => {
|
|
let _ = tauri::Emitter::emit(app, "app://navigate", "search");
|
|
}
|
|
"tray_settings" => {
|
|
let _ = tauri::Emitter::emit(app, "app://navigate", "settings");
|
|
}
|
|
"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,
|
|
profile_get_by_did,
|
|
search,
|
|
post_get,
|
|
fetch_thread,
|
|
fetch_notifications,
|
|
notification_count,
|
|
mark_notifications_seen,
|
|
fetch_followers,
|
|
fetch_following,
|
|
like_post,
|
|
unlike_post,
|
|
repost_post,
|
|
unrepost_post,
|
|
follow_user,
|
|
unfollow_user,
|
|
status_pds,
|
|
fetch_blob,
|
|
pick_and_upload_image,
|
|
show_notification,
|
|
open_external_url,
|
|
profile_get_record,
|
|
profile_set,
|
|
get_api_urls,
|
|
])
|
|
.run(tauri::generate_context!())
|
|
.expect("error while running maarcadetweet");
|
|
}
|
|
#[tauri::command]
|
|
async fn profile_get_record(
|
|
state: tauri::State<'_, AppState>,
|
|
) -> Result<Option<serde_json::Value>, String> {
|
|
let sess = state
|
|
.store
|
|
.load()
|
|
.ok_or_else(|| "not logged in".to_string())?;
|
|
state
|
|
.pds
|
|
.get_profile_record(&sess.did, &sess.access_jwt)
|
|
.await
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Frontend-side base URLs the Tauri shell was started with. Used by
|
|
/// the Svelte components to build absolute fetch URLs — a relative
|
|
/// `/api/...` resolves against the Vite dev origin (port 1430), not
|
|
/// the AppView (port 2584), and the Vite server has no proxy
|
|
/// configured, so the fetch lands on a 404 HTML page and
|
|
/// `response.json()` throws `SyntaxError`.
|
|
#[derive(serde::Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
struct ApiUrls {
|
|
pds_url: String,
|
|
appview_url: String,
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn get_api_urls(state: tauri::State<'_, AppState>) -> ApiUrls {
|
|
// Sync command — the URLs are immutable for the lifetime of the
|
|
// Tauri shell (read from MAARCADETWEET_*_URL at startup), so no
|
|
// async machinery is needed. Returns the AppView URL the
|
|
// frontend needs; PDS URL is exposed too so future fetch-based
|
|
// XRPC calls don't have to add their own command.
|
|
ApiUrls {
|
|
pds_url: state.pds.base_url.clone(),
|
|
appview_url: state.appview_url.clone(),
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn profile_set(
|
|
state: tauri::State<'_, AppState>,
|
|
fields: serde_json::Value,
|
|
) -> Result<(), String> {
|
|
let sess = state
|
|
.store
|
|
.load()
|
|
.ok_or_else(|| "not logged in".to_string())?;
|
|
let display_name = fields
|
|
.get("displayName")
|
|
.and_then(|v| v.as_str())
|
|
.map(str::to_string);
|
|
let description = fields
|
|
.get("description")
|
|
.and_then(|v| v.as_str())
|
|
.map(str::to_string);
|
|
let avatar_blob_cid = fields
|
|
.get("avatarBlobCid")
|
|
.and_then(|v| v.as_str())
|
|
.map(str::to_string);
|
|
let banner_blob_cid = fields
|
|
.get("bannerBlobCid")
|
|
.and_then(|v| v.as_str())
|
|
.map(str::to_string);
|
|
state
|
|
.pds
|
|
.set_profile(
|
|
&sess.did,
|
|
&serde_json::json!({
|
|
"displayName": display_name,
|
|
"description": description,
|
|
"avatarBlobCid": avatar_blob_cid,
|
|
"bannerBlobCid": banner_blob_cid,
|
|
}),
|
|
&sess.access_jwt,
|
|
)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
Ok(())
|
|
}
|