maarcadetweet: initial commit

AT Protocol PDS + AppView + Tauri Desktop Client, 160-char post limit.

- PDS (Rust + axum + sqlx)
  - Auth: createAccount, createSession, refreshSession
  - Records: createRecord, deleteRecord (race-safe via SELECT FOR UPDATE)
  - Feed: feed.like.create, feed.repost.create
  - Sync: getRepo, getBlocks, getLatestCommit, getRecord (with MST proof), listRepos
  - Identity: resolveHandle
  - MST: spec-conformant (at-mst crate, 27 tests)
  - Repo: signed commits, TID counter (monotonic, 4096 wrap safe)

- AppView (Rust + axum + sqlx)
  - Jetstream consumer (WebSocket, exponential backoff, 38k+ events indexed)
  - REST API: timeline/home (graph-aware), profile, search, post (with thread hydration)
  - Handle-sync worker (did:plc + did:web)
  - JSONB embed storage + thread columns (migration 0003)
  - Like/repost counter cache (migration 0004)

- Tauri 2 + Svelte 5 Desktop Client
  - System tray (Show/Compose/Quit menu)
  - OS notifications (tauri-plugin-notification)
  - Auto-update (tauri-plugin-updater, placeholder endpoint)
  - Window-state (tauri-plugin-window-state)
  - 160-char compose with live counter
  - Image/Link embed rendering
  - LocalStorage-persisted like state
  - Timeline with poll (prepend new posts)
  - Custom TitleBar (transparent, no decorations)
  - Orange/IBM Plex Mono maarcade design

Tests: 231 Rust + 9 vitest = 240 passed.
This commit is contained in:
tomdebone
2026-07-05 20:01:31 +02:00
commit c586fd39c9
134 changed files with 35279 additions and 0 deletions
+348
View File
@@ -0,0 +1,348 @@
import { invoke } from "@tauri-apps/api/core";
import { writable } from "svelte/store";
export type Session = {
did: string;
handle: string;
access_jwt: string;
refresh_jwt: string;
};
function createSessionStore() {
const { subscribe, set } = writable<Session | null>(null);
return {
subscribe,
async load() {
try {
const s = await invoke<Session | null>("current_session");
set(s);
} catch (e) {
console.error("current_session failed", e);
}
},
async login(handle: string, password: string) {
const s = await invoke<Session>("auth_login", { identifier: handle, password });
set(s);
return s;
},
async register(handle: string, password: string) {
const s = await invoke<Session>("auth_register", { handle, password });
set(s);
return s;
},
async logout() {
try {
await invoke("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;
};
export type TimelineResponse = {
posts: Post[];
cursor: string | null;
};
export type ProfileResponse = {
did: string;
handle: string;
posts: Post[];
followers: number;
following: 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;
};
export async function createPost(text: string): Promise<Post> {
// 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.
return await invoke<any>("post_create", { text });
}
export async function describeServer(): Promise<any> {
return await invoke("pds_describe");
}
export async function pdsStatus(): Promise<any> {
return await invoke("status_pds");
}
export async function fetchTimeline(
did: string,
cursor: string | null = null,
limit: number = 30,
): Promise<TimelineResponse> {
return await invoke<TimelineResponse>("timeline_home", {
did,
cursor,
limit,
});
}
export async function fetchProfile(handle: string): Promise<ProfileResponse> {
return await invoke<ProfileResponse>("profile_get", { handle });
}
export async function fetchSearch(
q: string,
limit: number = 30,
): Promise<SearchResponse> {
return await invoke<SearchResponse>("search", { q, limit });
}
export async function fetchPost(uri: string): Promise<ThreadResponse> {
return await invoke<ThreadResponse>("post_get", { uri });
}
/// `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<RepoWriteResult> {
return await invoke<RepoWriteResult>("like_post", {
subjectUri,
subjectCid,
});
}
export async function unlikePost(likeUri: string): Promise<DeleteRecordResult> {
return await invoke<DeleteRecordResult>("unlike_post", { likeUri });
}
export async function repostPost(
subjectUri: string,
subjectCid: string,
): Promise<RepoWriteResult> {
return await invoke<RepoWriteResult>("repost_post", {
subjectUri,
subjectCid,
});
}
export async function unrepostPost(
repostUri: string,
): Promise<DeleteRecordResult> {
return await invoke<DeleteRecordResult>("unrepost_post", { repostUri });
}
/// Fire-and-forget user-visible error toast. Implemented as a
/// `window` `CustomEvent` so any component can show errors 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 } }),
);
}
/// 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<void> {
try {
await invoke("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" | "compose") => 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://compose", () => handler("compose"));
unlisteners.push(u1, u2);
return () => {
for (const u of unlisteners) u();
};
}
/// 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 CID, because:
// * the blob bytes are addressed by content hash, so the same
// CID always resolves to the same bytes regardless of DID;
// * the `<img>` 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<string, string>();
/// Fetch the raw blob bytes for `cid` and return an object URL
/// suitable for `<img src={...}>`. Caches the URL in-process so
/// navigating the timeline doesn't re-download already-seen
/// images.
export async function fetchBlob(
did: string,
cid: string,
): Promise<string> {
const cached = _blobUrlCache.get(cid);
if (cached) return cached;
const bytes: number[] = await invoke<number[]>("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(cid, url);
return url;
}
/// Drop the cached object URL and remove it. Components should
/// call this when an `<img>` 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(cid: string): void {
const url = _blobUrlCache.get(cid);
if (url) {
URL.revokeObjectURL(url);
_blobUrlCache.delete(cid);
}
}
/// Test/internal: clear the in-memory blob cache.
export function clearBlobCache(): void {
for (const url of _blobUrlCache.values()) {
URL.revokeObjectURL(url);
}
_blobUrlCache.clear();
}