Files
maarcadetweet/crates/tauri-app/src-tauri/src/lib.rs
T
tomdebone 550b89673d feat(tauri-app): expand tray menu + open_external_url + Profile/Search items
Tray menu now has:
  Show maarcadetweet
  Home
  Compose
  Profile
  Search
  ----
  Quit

The Home/Profile/Search items emit 'app://navigate' events
which the frontend's listenTrayEvents translates to view
switches. The compose and show events continue to be
separate event types ('app://compose', 'app://show').

open_external_url Tauri command takes a URL, validates it's
http(s), and uses tauri-plugin-shell to open it in the user's
default browser. The frontend's openExternalUrl falls back
to window.open in the browser preview (no Tauri runtime).

svelte-check error fix: tauriCall<T>(cmd, fallback, args?)
had the second call argument as 'undefined' instead of 'null'
on the call site for session.load(). The TypeScript compiler
correctly noted that the fallback type 'T' (here Session |
null) couldn't be undefined. Replaced with 'null' and
dropped the trailing null args argument (it's optional).
2026-07-07 17:31:09 +02:00

706 lines
22 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(&current.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>,
) -> 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;
}
}
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())
}
/// `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),
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");
}