import { invoke, isTauri } from "@tauri-apps/api/core"; import { writable } from "svelte/store"; /** * Guard the Tauri runtime. When the page is served by `vite dev` * for browser-only preview (no Tauri webview), `__TAURI_INTERNALS__` * is undefined and the bare `invoke` call throws * `TypeError: Cannot read properties of undefined (reading * 'invoke')`. That error fired inside `onMount` during * `session.load()` and crashed the entire Svelte mount, leaving * the user with a white page. We wrap every Tauri call in two * helpers: * - `tauriCall(cmd, fallback)` for loads — returns the fallback * when no Tauri runtime is present (e.g. session.load() returns * null). * - `safeInvoke(cmd, args)` for actions that MUST hit the runtime * (login, register, etc.) — throws a friendly error so the UI * can show a "running in browser preview" notice. */ async function tauriCall(cmd: string, fallback: T, args?: Record): Promise { if (!isTauri()) { return fallback; } return invoke(cmd, args); } /// Base URLs the Tauri shell was started with. Exposed via the /// `get_api_urls` command so the Svelte components can build /// absolute fetch URLs — a relative `/api/...` resolves against /// the Vite dev origin (port 1430), not the AppView (port 2584), /// and `response.json()` then throws `SyntaxError` on the 404 /// HTML page. Cached after the first successful call. let _apiUrlsCache: { pdsUrl: string; appviewUrl: string } | null = null; export type ApiUrls = { pdsUrl: string; appviewUrl: string }; /// Fetch the AppView + PDS base URLs from the Rust shell. Returns /// the cached value on subsequent calls. export async function getApiUrls(): Promise { if (_apiUrlsCache) return _apiUrlsCache; const urls = await safeInvoke("get_api_urls"); _apiUrlsCache = urls; return urls; } /// Convenience: just the AppView base URL (the only one the UI /// currently needs for direct fetch calls). Same caching as /// `getApiUrls`. export async function getAppviewUrl(): Promise { const { appviewUrl } = await getApiUrls(); return appviewUrl; } /** * Strict variant of `tauriCall` for actions that MUST hit the * Tauri runtime (login, register, logout, post, like, etc.). In * the browser preview this throws a friendly Error so the UI can * show a "running in browser preview" notice. In the Tauri * webview it falls through to a normal `invoke` call. * * **Auto-refresh on 401**: the access JWT expires after 1 hour. When * the PDS rejects our token with `TokenInvalid` (the rusty * `routes::auth` handlers return `{"error":"TokenInvalid",...}`), * we ask the Rust shell for a fresh access JWT via the * `auth_refresh` Tauri command. The Rust side reads the stored * refresh JWT (valid for 90 days) and rotates both. We retry * exactly once on the same `cmd` + `args`. The `auth_*` commands * themselves are skipped so a failing login doesn't trigger an * infinite refresh loop. */ async function safeInvoke(cmd: string, args?: Record): Promise { if (!isTauri()) { throw new Error(`Tauri command ${cmd} requires the desktop runtime`); } try { return await invoke(cmd, args); } catch (e: unknown) { if (!isTokenInvalid(e) || cmd.startsWith("auth_")) throw e; const fresh = await session.refresh(); if (!fresh) throw e; return await invoke(cmd, args); } } /// Sniff out a `TokenInvalid` response from the Rust error string. /// Returns true when the error message looks like an expired/ /// invalid JWT (the PDS uses a stable `"TokenInvalid"` code in its /// JSON error body, which `@tauri-apps/api/core` surfaces verbatim). function isTokenInvalid(e: unknown): boolean { if (typeof e !== "object" || e === null) return false; const msg = (e as { message?: string }).message ?? String(e); if (!msg) return false; return msg.includes("TokenInvalid") || msg.includes("ExpiredSignature"); } export type Session = { did: string; handle: string; access_jwt: string; refresh_jwt: string; }; function createSessionStore() { const { subscribe, set } = writable(null); // Coalesce concurrent refresh requests into one — every safeInvoke // call that hits a 401 would otherwise race to call auth_refresh in // parallel. The pending promise is reset to `null` exactly once in // the finally block; subsequent callers await the same one. let pendingRefresh: Promise | null = null; return { subscribe, /// Mint a fresh access JWT from the stored refresh JWT. Called /// automatically by [`safeInvoke`] on `TokenInvalid` responses. /// Returns the new session, or `null` if the refresh itself failed /// (e.g. refresh JWT expired; at that point the user has to log /// in again). async refresh(): Promise { if (pendingRefresh) return pendingRefresh; pendingRefresh = (async () => { try { const s = await invoke("auth_refresh"); set(s); return s; } catch (e) { console.warn("session refresh failed", e); return null; } finally { pendingRefresh = null; } })(); return pendingRefresh; }, async load() { const s = await tauriCall("current_session", null); set(s); }, async login(handle: string, password: string) { if (!isTauri()) { throw new Error("login requires the Tauri desktop runtime"); } const s = await safeInvoke("auth_login", { identifier: handle, password }); set(s); return s; }, async register(handle: string, password: string) { if (!isTauri()) { throw new Error("register requires the Tauri desktop runtime"); } const s = await safeInvoke("auth_register", { handle, password }); set(s); return s; }, async logout() { try { await safeInvoke("auth_logout"); } catch (e) { console.error("logout failed", e); } set(null); }, }; } export const session = createSessionStore(); /// AT-Protocol embed variants. We keep the on-the-wire JSON verbatim /// (instead of narrowing to one specific shape per variant) so that /// adding a new embed type upstream doesn't require a frontend change. /// The UI sniffs `embed.$type` to decide which sub-component to render. export type Embed = { $type: string; images?: EmbedImage[]; external?: EmbedExternal; record?: EmbedRecord; media?: EmbedRecordWithMedia; [k: string]: unknown; }; export type EmbedImage = { alt?: string; image?: unknown; aspectRatio?: { width: number; height: number }; }; export type EmbedExternal = { uri: string; title?: string; description?: string; thumb?: string; }; export type EmbedRecord = { uri: string; cid?: string; author?: { did: string; handle?: string }; value?: { text?: string; createdAt?: string }; }; export type EmbedRecordWithMedia = Embed & { record: EmbedRecord; media: { images?: EmbedImage[]; external?: EmbedExternal }; }; /// Post shape returned by the AppView REST API. The Tauri command /// (also named `Post` on the Rust side) re-exports this as /// `appview_client::PostDto` and the frontend receives it as-is. export type Post = { uri: string; did: string; handle: string; rkey: string; collection: string; text: string; cid: string; parent_uri?: string | null; root_uri?: string | null; embed?: Embed | null; langs: string[]; created_at: string; like_count?: number; repost_count?: number; /// Resolved author-avatar CID from the AppView's `profiles` /// cache. NULL when the user has no profile record yet. avatar_cid?: string | null; }; export type TimelineResponse = { posts: Post[]; cursor: string | null; }; export type ProfileResponse = { did: string; handle: string; posts: Post[]; followers: number; following: number; display_name?: string | null; description?: string | null; avatar_cid?: string | null; banner_cid?: string | null; post_count: number; }; export type SearchResponse = { posts: Post[]; q: string; }; /// `GET /api/post/{uri}` response. Used by the UI when the user /// expands a reply to fetch the parent + root in one round trip. /// /// `like_count` and `repost_count` are present when the post was /// found; they're `undefined` (or absent) for the "not in index" /// sentinel response (where `post` is null). AppView has no auth /// yet, so `viewer_liked` / `viewer_reposted` aren't returned. export type ThreadResponse = { post: Post | null; thread: { parent: Post | null; root: Post | null; }; like_count?: number; repost_count?: number; }; /// Reply block for `app.bsky.feed.post#reply`. Both `root` and /// `parent` are `com.atproto.repo.strongRef`s (uri + cid). For a /// top-level reply to a single post, `root` and `parent` point at /// the same strongRef. The Rust `post_create` command wires this /// onto the record's `reply` field. export type ReplyRef = { root: { uri: string; cid: string }; parent: { uri: string; cid: string }; }; export async function createPost( text: string, embed?: unknown | null, reply?: ReplyRef | null, ): Promise { // The Rust post_create command returns a different shape (uri+cid // only), but we keep the call simple: it gives us the cid we need // to show the "ok" toast. // `embed` is forwarded verbatim; the caller is responsible for // shaping it as an `app.bsky.embed.images` / `.external` / etc. // record. Pass `null` or `undefined` to omit. // `reply` is the reply block (root + parent strongRefs); `null` or // `undefined` means "top-level post" (no reply block on the record). return await safeInvoke("post_create", { text, embed: embed ?? null, reply: reply ?? null, }); } /// `com.atproto.uploadBlob` wrapped with a file picker. Opens a /// native OS dialog (via `tauri-plugin-dialog`), uploads the chosen /// file to the user's PDS, and returns the parsed blob reference. /// /// Returns `null` when the user cancels the picker. The Tauri shell /// enforces a 1 MiB cap (matching the PDS's `MAX_BLOB_SIZE`) and /// surfaces other errors via the rejected promise. export async function pickAndUploadImage(): Promise<{ cid: string; mimeType: string; size: number; } | null> { const r = await safeInvoke<{ blob: { $type: string; ref: { $link: string }; mimeType: string; size: number; }; } | null>("pick_and_upload_image"); if (!r) return null; return { cid: r.blob.ref.$link, mimeType: r.blob.mimeType, size: r.blob.size, }; } /// Helper that builds the `app.bsky.embed.images` embed for a single /// image blob, ready to drop into a post record. export function makeImagesEmbed(blob: { cid: string; mimeType: string; size: number; }): Record { return { $type: "app.bsky.embed.images", images: [ { alt: "", image: { $type: "blob", ref: { $link: blob.cid }, mimeType: blob.mimeType, size: blob.size, }, }, ], }; } export async function describeServer(): Promise { return await safeInvoke("pds_describe"); } export async function pdsStatus(): Promise { return await safeInvoke("status_pds"); } export async function fetchTimeline( did: string, cursor: string | null = null, limit: number = 30, ): Promise { return await safeInvoke("timeline_home", { did, cursor, limit, }); } export async function fetchProfile(handle: string): Promise { return await safeInvoke("profile_get", { handle }); } /// Resolve a profile by DID (`GET /api/profile?did=…`) instead of by /// handle. /// /// Use this — not [`fetchProfile`] — whenever the actor came out of a /// notification or a follower/following list. The AppView synthesises /// a placeholder `handle` for actors it has neither a profile row nor /// an indexed post for: a truncated DID with an ellipsis, e.g. /// `"did:plc:f5…"`. That string matches nothing on the way back in, and /// the server answers with a synthetic empty profile (`did: ""`, zero /// posts) rather than an error — so a handle-based navigation from /// those lists is a silent dead end. The DID in the DTO is always /// real, so navigate by that. export async function fetchProfileByDid( did: string, ): Promise { return await safeInvoke("profile_get_by_did", { did }); } export async function fetchSearch( q: string, limit: number = 30, ): Promise { return await safeInvoke("search", { q, limit }); } export async function fetchPost(uri: string): Promise { return await safeInvoke("post_get", { uri }); } /// `GET /api/thread?uri=…` — the *full* thread around a post: the /// entire ancestor chain plus the direct replies, in one round trip. /// /// `parents` is ordered root-first (so `parents[parents.length - 1]` /// is the immediate parent) and `replies` oldest-first. A `parents[0]` /// whose own `parent_uri` is non-null means "the chain continues /// above but wasn't loaded" — the walk is depth-limited server-side. /// /// `post` is `null` when the URI isn't in the index; the counters are /// then absent too, so a single field check renders "post not found". /// `viewer_liked` / `viewer_reposted` are `undefined` when no /// `viewerDid` was passed — that means "unknown", not "false". export type ThreadFullResponse = { post: Post | null; parents: Post[]; root: Post | null; replies: Post[]; like_count?: number; repost_count?: number; viewer_liked?: boolean; viewer_reposted?: boolean; }; export async function fetchThread( uri: string, viewerDid: string | null = null, ): Promise { return await safeInvoke("fetch_thread", { uri, viewerDid, }); } /// One row of `GET /api/notifications`, hydrated server-side with the /// author's profile and the subject post's text so the list renders /// without any follow-up fetch. /// /// `kind` is typed as the four known values plus `string` so an /// unknown kind added upstream still type-checks here; the UI's /// `notificationText` falls back to a generic line. export type NotificationKind = "like" | "repost" | "follow" | "reply"; export type Notification = { id: number; kind: NotificationKind | string; author_did: string; /// Without a leading `@` (the UI renders `@{handle}`). Never null — /// the AppView falls back to a truncated DID. author_handle: string; author_display_name?: string | null; author_avatar_cid?: string | null; /// The post this is about. `null` for `"follow"`. For /// `"like"`/`"repost"` it's the recipient's own post; for `"reply"` /// it's the reply itself. subject_uri: string | null; subject_text?: string | null; created_at: string; /// When the AppView indexed it — the list's sort key and the value /// to echo back as `seenAt`. indexed_at: string; /// `null` while unread. read_at: string | null; }; export type NotificationsResponse = { notifications: Notification[]; cursor: string | null; }; /// German UI copy for a notification row. Kept next to the type (and /// exported) so the mapping is unit-testable without mounting a /// component. An unrecognised `kind` gets a neutral fallback rather /// than an empty line. export function notificationText(kind: string): string { switch (kind) { case "like": return "hat deinen Post geliked"; case "repost": return "hat repostet"; case "follow": return "folgt dir jetzt"; case "reply": return "hat geantwortet"; default: return "hat interagiert"; } } /// Single-glyph icon for a notification row, same switch as /// [`notificationText`]. Monospace glyphs rather than SVGs so the /// list keeps the terminal look of the rest of the UI. export function notificationIcon(kind: string): string { switch (kind) { case "like": return "♥"; case "repost": return "⇄"; case "follow": return "+"; case "reply": return "↩"; default: return "•"; } } /// One page of notifications, newest first. Same opaque-cursor /// contract as [`fetchTimeline`]: pass the previous response's /// `cursor` to page down, and `cursor === null` means end of list. export async function fetchNotifications( did: string, cursor: string | null = null, limit: number = 30, ): Promise { return await safeInvoke("fetch_notifications", { did, cursor, limit, }); } /// Unread-notification count for the NavRail badge. Cheap enough to /// poll on the same cadence as the timeline refresh. export async function notificationCount(did: string): Promise { const r = await safeInvoke<{ count: number }>("notification_count", { did }); return r.count; } /// Mark every notification indexed at or before `seenAt` as read. /// Pass the `indexed_at` of the topmost row the user actually sees, so /// a notification arriving mid-scroll isn't swallowed. `null` marks /// everything currently unread. Idempotent — a repeat call reports /// `updated: 0`. export async function markNotificationsSeen( did: string, seenAt: string | null = null, ): Promise<{ ok: boolean; updated: number }> { return await safeInvoke<{ ok: boolean; updated: number }>( "mark_notifications_seen", { did, seenAt }, ); } /// A minimal profile card from `GET /api/followers` / `/api/following`. /// Deliberately not a full `ProfileResponse` — a page of 30 followers /// would otherwise be 30 post queries server-side. Click a row and the /// UI navigates to the full profile by `handle`. export type ActorProfile = { did: string; handle: string; display_name?: string | null; avatar_cid?: string | null; }; export type ActorListResponse = { profiles: ActorProfile[]; cursor: string | null; }; export async function fetchFollowers( did: string, cursor: string | null = null, limit: number = 30, ): Promise { return await safeInvoke("fetch_followers", { did, cursor, limit, }); } export async function fetchFollowing( did: string, cursor: string | null = null, limit: number = 30, ): Promise { return await safeInvoke("fetch_following", { did, cursor, limit, }); } /// `app.bsky.feed.like.create` — Tauri command. Builds the /// flat-shape like body on the Rust side, signs a commit, pushes /// to the AppView. Returns the new like's `uri` and `cid`. export type RepoWriteResult = { uri: string; cid: string }; /// `com.atproto.repo.deleteRecord` — Tauri command. Used for /// both unlike and unrepost. The Rust side splits the URI into /// `rkey` and sends it. export type DeleteRecordResult = { commit: { cid: string; rev: string } }; export async function likePost( subjectUri: string, subjectCid: string, ): Promise { return await safeInvoke("like_post", { subjectUri, subjectCid, }); } export async function unlikePost(likeUri: string): Promise { return await safeInvoke("unlike_post", { likeUri }); } export async function repostPost( subjectUri: string, subjectCid: string, ): Promise { return await safeInvoke("repost_post", { subjectUri, subjectCid, }); } export async function unrepostPost( repostUri: string, ): Promise { return await safeInvoke("unrepost_post", { repostUri }); } /// `followUser(targetDid)` — create an `app.bsky.graph.follow` record /// on the user's PDS. Returns `{ uri, cid }` — the client caches /// `uri` in localStorage so `unfollowUser(uri)` can delete the /// record without needing a "list my follows" round-trip. export async function followUser( targetDid: string, ): Promise { return await safeInvoke("follow_user", { targetDid }); } export async function unfollowUser( followUri: string, ): Promise { return await safeInvoke("unfollow_user", { followUri, }); } /// Fire-and-forget user-visible toast. Implemented as a `window` /// `CustomEvent` so any component can show toasts without pulling /// in a global store. `App.svelte` listens for the event and /// renders the toast UI. export function showError(text: string): void { if (typeof window === "undefined") return; window.dispatchEvent( new CustomEvent("maarcadetweet:toast", { detail: { kind: "error", text } }), ); } export function showInfo(text: string): void { if (typeof window === "undefined") return; window.dispatchEvent( new CustomEvent("maarcadetweet:toast", { detail: { kind: "info", text } }), ); } /// Show a native OS notification. Thin wrapper around the /// `show_notification` Tauri command. The Rust side also emits an /// `app://notification` event with the same payload, so the click /// listener (registered via `listenNotification`) can route to a URL. /// /// Pass `url` to make the notification clickable: when the user /// clicks, the Rust command focuses the main window and the JS /// listener navigates. export async function showNotification( title: string, body: string, url?: string, ): Promise { try { await safeInvoke("show_notification", { title, body, url: url ?? null }); } catch (e) { console.error("show_notification failed", e); } } /// Subscribe to system-tray menu events. The Tauri tray menu emits /// `app://show` ("Show maarcadetweet") and `app://compose` ("Compose"); /// the Rust side already turns left-clicks into `app://show` and the /// "Quit" menu item into a clean `app.exit(0)`. /// /// The handler receives the event payload (empty object for these /// cases). Returns an unsubscribe function. export async function listenTrayEvents( handler: (event: "show" | "home" | "compose" | "profile" | "search" | "settings") => void, ): Promise<() => void> { const { listen } = await import("@tauri-apps/api/event"); const unlisteners: Array<() => void> = []; const u1 = await listen("app://show", () => handler("show")); const u2 = await listen("app://navigate", (e) => { handler(e.payload as "home" | "profile" | "search" | "settings"); }); const u3 = await listen("app://compose", () => handler("compose")); unlisteners.push(u1, u2, u3); return () => { for (const u of unlisteners) u(); }; } /// Open an external URL in the user's default browser. The Rust /// `open_external_url` command enforces http(s) only; in the /// browser preview where no Tauri runtime is present, fall back /// to `window.open` and treat a popup-blocker denial as /// "fine, user can copy the URL themselves". export type ProfileRecord = { displayName?: string; description?: string; avatar?: { ref: { $link: string }; mimeType?: string; size?: number }; banner?: { ref: { $link: string }; mimeType?: string; size?: number }; }; /// Read the authenticated user's `app.bsky.actor.profile` record. /// Returns `null` if no profile record exists yet (a brand-new /// account, or a user whose PDS hasn't pushed one). export async function getMyProfile(): Promise { return await safeInvoke("profile_get_record"); } /// Read-modify-write the authenticated user's profile. The Rust /// `profile_set` command fetches the existing record, overlays /// the supplied fields, and writes a new commit. `undefined` fields /// are preserved. export async function setMyProfile(fields: { displayName?: string; description?: string; avatarBlobCid?: string; bannerBlobCid?: string; }): Promise { return await safeInvoke("profile_set", fields); } export async function openExternalUrl(url: string): Promise { try { if (isTauri()) { await safeInvoke("open_external_url", { url }); return; } } catch (e) { console.warn("open_external_url failed", e); } // Browser fallback (vite dev preview). try { window.open(url, "_blank", "noopener,noreferrer"); } catch (e) { console.warn("window.open failed (popup blocker?)", e); } } /// Subscribe to `app://notification` events emitted by the Rust /// `show_notification` command. Used to focus the window and /// navigate when the user clicks the notification. export async function listenNotification( handler: (payload: { title: string; body: string; url: string | null; }) => void, ): Promise<() => void> { const { listen } = await import("@tauri-apps/api/event"); const u = await listen<{ title: string; body: string; url: string | null }>( "app://notification", (e) => handler(e.payload), ); return u; } // -- blob fetch + cache ----------------------------------------------------- // // Image embeds reference blobs by CID. The blob bytes themselves // live on the user's PDS — the AppView only has the CID. The Tauri // shell handles the PDS HTTP call (`fetch_blob` command) so the // frontend never has to know the PDS URL. // // We cache the *resolved object URL* (not the raw bytes) keyed // by `(did, cid)`, NOT just `cid`: // * the blob *bytes* are addressed by content hash, so the same // CID *usually* resolves to the same bytes regardless of DID; // * BUT in the atproto sync spec, `com.atproto.sync.getBlob` is // intentionally unauthenticated and keyed by `(did, cid)` on // the server. A future change to a per-DID access control // model would make the bytes differ per DID — caching by CID // alone would then leak the first responder's bytes to every // subsequent viewer. // * the `` element takes an object URL, not raw bytes, so // handing the URL straight back to the caller saves a // Blob/URL.createObjectURL call per render. const _blobUrlCache = new Map(); const _blobKey = (did: string, cid: string) => `${did}/${cid}`; /// Fetch the raw blob bytes for `cid` and return an object URL /// suitable for ``. Caches the URL in-process so /// navigating the timeline doesn't re-download already-seen /// images. Keyed by `(did, cid)` for the security reason above. export async function fetchBlob( did: string, cid: string, ): Promise { const key = _blobKey(did, cid); const cached = _blobUrlCache.get(key); if (cached) return cached; const bytes: number[] = await safeInvoke("fetch_blob", { did, cid, }); const u8 = new Uint8Array(bytes); if (u8.length === 0) { throw new Error(`empty blob for cid ${cid}`); } const blob = new Blob([u8]); const url = URL.createObjectURL(blob); _blobUrlCache.set(key, url); return url; } /// Drop the cached object URL and remove it. Components should /// call this when an `` is unmounted to avoid leaking the /// underlying Blob. For a Tauri WebView with at most a few /// dozen visible images the OS cleans up anyway, but explicit /// revocation makes long sessions friendlier on memory. export function releaseBlob(did: string, cid: string): void { const url = _blobUrlCache.get(_blobKey(did, cid)); if (url) { URL.revokeObjectURL(url); _blobUrlCache.delete(_blobKey(did, cid)); } } /// Test/internal: clear the in-memory blob cache. export function clearBlobCache(): void { for (const url of _blobUrlCache.values()) { URL.revokeObjectURL(url); } _blobUrlCache.clear(); }