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 { 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 { 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 { 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 { 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, String> { Ok(state.store.load()) } #[tauri::command] async fn post_create( state: tauri::State<'_, AppState>, text: String, embed: Option, ) -> Result { 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; } } 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, 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 { 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 { 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 { 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 { 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 { 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, limit: Option, ) -> Result { 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 { 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, ) -> Result { 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 { state .appview .fetch_post(&uri) .await .map_err(|e| e.to_string()) } #[tauri::command] async fn status_pds(state: tauri::State<'_, AppState>) -> Result { 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, 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, 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, ) -> 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_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"); // 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 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, &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_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, pick_and_upload_image, show_notification, open_external_url, ]) .run(tauri::generate_context!()) .expect("error while running maarcadetweet"); }