Compare commits

...
10 Commits
Author SHA1 Message Date
tomdebone a5b1c889dc fix(tauri-app): auto-refresh access JWT on TokenInvalid responses
The PDS access JWT expires after 1 hour; the refresh JWT lasts
90 days. Before this commit, every action (post, like, follow,
post create, etc.) started failing with the user's first action
after the hour mark, forcing a manual re-login. Now safeInvoke
catches the TokenInvalid / ExpiredSignature response, calls the
'auth_refresh' Tauri command to mint a fresh access JWT, then
retries the original call exactly once.

Concurrent 401s during a refresh-window share a single in-flight
'auth_refresh' call via the pendingRefresh promise — without it,
a single expired JWT would trigger N parallel refreshes on the
Rust side, which would issue N new refresh JWTs and silently drop
all but the last one on save().

The refresh() method is exposed on the session store so callers
outside safeInvoke (the explicit 'session.refreshed' toast etc.)
can also trigger it. The auth_* commands themselves are
excluded from the retry path so a bad login doesn't loop into
'refresh → 401 → refresh' forever.

Wire shape match: the auth_refresh command returns AccountSession
{ did, handle, access_jwt, refresh_jwt } which matches our
Session type, so the store can 'set(s)' directly without a
field-by-field copy.
2026-07-07 21:58:26 +02:00
tomdebone a226a92c12 fix(appview): skip did:key in handle-sync SQL — they blocked progress
The handle-sync worker picks the next BATCH_SIZE=100 distinct
DIDs with 'handle = \"\"' via 'ORDER BY did LIMIT 100'. But
'alphabetically' (which is what ORDER BY on a text column
produces) puts 'did🔑' before 'did:plc:' before 'did:web:'.
The 'dispatch' function returns 'Ok(None)' for 'did🔑'
(no resolver exists), counts that as 'skipped', and exits the
batch. Result: every pass processes the same ~3700 'did🔑'
rows first and never reaches any resolvable 'did:plc:' DID.

Fix at the SQL layer: 'WHERE did LIKE \"'did:plc:%\"' OR did
LIKE \"'did:web:%\"' \"'\") so every batch is real work. After
the first run on a fresh start, 'resolved=254 failed=0 skipped=0'
instead of 'resolved=0 failed=0 skipped=100'.
2026-07-07 21:50:47 +02:00
tomdebone 73e56fd788 fix(appview): store handle from Jetstream identity + account events
Jetstream 'identity' events are emitted every time a DID's handle
changes; the 'account' variant sometimes carries the verified
handle too. The AppView was logging both kinds as 'identity event
(logged only)' and 'account event (logged only)' — discarding the
attached handle.

Now we extract 'identity.handle' (falling back to
'account.handle'), and run it through a new
'indexer::backfill_handle(db, did, handle)'. The
'WHERE handle IS DISTINCT FROM $1' guard makes the UPDATE a
no-op when the value is already correct, so concurrent PDS
ingests and identity replays never fight.

End-of-stale-state: existing rows whose 'identity' event fired
before this code shipped will still be empty. Those get back-filled
over time as DIDs re-emit identity events, plus the 5-minute
handle-sync worker (next commit) handles the bulk for the rest.
2026-07-07 21:50:45 +02:00
tomdebone 78b752c993 fix(appview): populate handle from PDS ingest + backfill race-safely
The AppView side of the PDS→AppView ingest path. Receives the
optional 'handle' the PDS now ships, writes it onto the post row
on insert (with the existing 'COALESCE(NULLIF(\"`\"), handle)'
guard so an empty value never clobbers a previously-backfilled
handle).

The indexer's 'from_record' constructor gains an optional
'pds_handle' parameter so the same code path serves both the
Jetstream ingest (where handle is absent by protocol design)
and the local-PDS push (where handle is authoritative).

Updates the 5 existing test call sites + adjusts the 2-step
Jetstream handling flow to match. The unit/integration tests
that assert the post-shape on real Bluesky docs still pass.
2026-07-07 21:50:43 +02:00
tomdebone 184e0dfe03 fix(pds-server): forward poster handle to AppView on ingest push
The PDS's best-effort push to /internal/ingest-commit didn't
include the poster's handle. The AppView's indexer then stored
'\'' (empty) and the timeline UI fell back to '@did:plc:<snip>…'
synthetic identifiers — which is fine for Bluesky (PLC directory
resolves the rest), but local-PDS users have 'did🔑' DIDs that
no resolver can look up, so the synthetic handle stuck forever
and the profile endpoint could never resolve 'handle → did'.

Plumb the handle through:
- appview_push.rs: IngestCommitBody gains an optional 'handle'
  field; push_create / push_follow_create take Option<&str>
- routes/helpers.rs: new 'lookup_handle(state, did)' helper that
  hits the 'users' table (in practice always finds the row for an
  authenticated route; logs a warning otherwise)
- routes/repo.rs (createRecord) and routes/feed.rs (feed.like.create):
  resolve 'did → handle' from the users table before the spawned
  ingest push, pass it through

The AppView-side companion commit stores the handle on the new
row and adds a Jetstream identity-event backfill, so by the
time this PR is merged timelines render real '@handle' again.
2026-07-07 21:50:41 +02:00
tomdebone abea4d2a8a fix(tauri-app): kill effect_update_depth_exceeded via untrack + setView
Two independent Svelte 5 effect-loop bugs that triggered the
same 'effect_update_depth_exceeded' guard:

1. PostCard.svelte: the embed-quote-fetch $effect and the
   likedBox $effect.pre read a state variable (quotedLoading /
   likedBox) and then synchronously wrote to it in the same
   effect run. Svelte 5's effect tracker schedules
   possible_effect_self_invalidation on the touched state, the
   effect re-fires immediately, and the cycle trips the
   'flush_count > 1000' guard. With ~30 PostCards mounting on
   login the per-card loop compounds into the depth exceeded
   error. Wrap the read+write blocks in untrack() so the
   hydration flags don't contribute to the effect's dep set;
   the outer 'post.embed.uri / post.did / post.rkey' reads
   remain tracked so navigation between cards still triggers a
   fresh hydrate.

2. App.svelte: the four $effect blocks (home-refresh,
   home-poll, profile-refresh, search-debounce) lived and died
   together. Even after splitting, Svelte 5 still flagged the
   call chain into refreshTimeline / refreshProfile because
   their sync prelude writes 'timelineLoading = true' /
   'profileLoading = true' while the effect already tracks the
   same downstream state via the proxy. Drive everything
   imperatively through a single setView(v) function and move
   the 5s poll into the session.subscribe callback, which fires
   only on actual login/logout transitions. setView is the
   single point that flips view AND triggers the right refresh
   per destination — NavRail on_select, LoginScreen onLogin,
   handleLogout, the 'back to timeline' button, and the tray
   navigate-event bridge all route through it now.

Also: NavRail and StatusBar self-style their grid-area in
their own component styles ('grid-area: rail' / 'status') so
App.svelte doesn't need the fragile '$state.s-XXX > nav.rail'
cross-component selector that Svelte 5 was failing to match
in the Tauri webview, leaving rail buttons invisible to clicks.
2026-07-07 20:54:35 +02:00
tomdebone d1fff87e34 fix(appview): empty profile instead of 404 for unindexed handles
resolve_profile returned 404 when the requested handle had no
posts in the local index — true for every local-PDS account
that hasn't yet had its posts ingested through the Jetstream
consumer. The UI then surfaced this as a raw 'err: appview:
profile returned 404' toast instead of an empty profile.

Return a profile row with the requested handle and zero counts
instead. The DID is left empty; the UI's 'copy did' button just
copies an empty string and the header falls back to the handle,
which is the right behaviour for a never-indexed local user.
2026-07-07 20:54:21 +02:00
tomdebone 647c3059b4 feat(tauri-app): Tauri 2 capabilities default + open_devtools in debug
The dev build was missing a capabilities/default.json so the
event:listen and notification:is-permission-granted plugins
denied every IPC call from main.ts (logged as 'event.listen not
allowed' etc. in DevTools). Add the minimum set needed by
client.ts / main.ts and the AppView/Tauri commands registered
in lib.rs.

Also open the webview devtools automatically on startup behind
a debug_assertions guard, so the frontend console + DOM inspector
are available without reaching for the macOS View menu.
2026-07-07 20:54:10 +02:00
tomdebone 6a82c906de tauri-app: tray settings menu item
Tray menu now includes a Settings entry that emits
'app://navigate' with payload 'settings', mirroring
home/profile/search. client.ts listenTrayEvents type
extended to include 'settings'.
2026-07-07 18:57:45 +02:00
tomdebone 43fc889d47 tauri-app: settings view + profile polish
- App.svelte: new 'settings' view with account/actions/about sections,
  sign-out button, pdsBase()/appviewBase() helpers, showError wired
  into logout, profile gains open-in-browser + sign-out + recent-posts
  heading
- NavRail: add settings item + gear icon, View union extended to 5
- tests: NavRail.test.ts now expects 5 buttons + covers settings click,
  NavRailHarness View type extended
2026-07-07 18:52:10 +02:00
19 changed files with 622 additions and 74 deletions
+64 -8
View File
@@ -10,6 +10,7 @@
use anyhow::Result; use anyhow::Result;
use at_firehose::JetstreamEvent; use at_firehose::JetstreamEvent;
use serde_json::Value;
use sqlx::PgPool; use sqlx::PgPool;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
@@ -133,13 +134,17 @@ impl IndexHandler {
} }
}, },
"identity" => { "identity" => {
trace!(did = %ev.did, "identity event (logged only)"); if let Err(e) = handle_identity(&self.db, &ev).await {
let _ = handle_identity(&ev); warn!(error = %e, did = %ev.did, "handle_identity failed");
return Ok(()); // don't advance cursor; let next replay retry
}
true true
} }
"account" => { "account" => {
trace!(did = %ev.did, "account event (logged only)"); if let Err(e) = handle_account(&self.db, &ev).await {
let _ = handle_account(&ev); warn!(error = %e, did = %ev.did, "handle_account failed");
return Ok(()); // don't advance cursor; let next replay retry
}
true true
} }
other => { other => {
@@ -164,16 +169,67 @@ impl IndexHandler {
} }
} }
fn handle_identity(_ev: &JetstreamEvent) -> Result<()> { /// `identity` event — Jetstream tells us a DID's handle changed.
info!("identity change (DID doc rotation)"); ///
/// The Jetstream payload includes `identity.handle` (the *current*
/// handle, since the event fires after every handle change) and
/// optionally `identity.did` (the DID — redundant with the outer
/// `ev.did` but we accept both). We pull the handle out and run it
/// through `indexer::backfill_handle` so every existing post row for
/// that DID gets the new value. The COALESCE guard inside
/// `indexer::PostRow::from_record` keeps empty strings from
/// clobbering this backfilled value when a later `commit` event
/// arrives.
async fn handle_identity(db: &PgPool, ev: &JetstreamEvent) -> Result<()> {
let handle = extract_handle(&ev.identity).or_else(|| extract_handle(&ev.account));
let Some(handle) = handle else {
// Some identity events carry only a DID-doc rotation signal
// with no handle payload — those are uninteresting for our
// purpose. Advance the cursor anyway.
debug!(did = %ev.did, "identity event without a usable handle payload");
return Ok(());
};
let rows = indexer::backfill_handle(db, &ev.did, &handle).await?;
info!(
did = %ev.did,
handle = %handle,
rows_updated = rows,
"backfilled handle on posts"
);
Ok(()) Ok(())
} }
fn handle_account(_ev: &JetstreamEvent) -> Result<()> { /// `account` event — Jetstream tells us an account's active/deactive
info!("account change (active/-status)"); /// status changed. We mirror the handle-backfill behaviour in case
/// the `account` payload carries the verified handle alongside
/// `active`; many real-world identities show the handle there even
/// when no `identity` event was emitted.
async fn handle_account(db: &PgPool, ev: &JetstreamEvent) -> Result<()> {
let Some(handle) = extract_handle(&ev.account) else {
return Ok(());
};
let rows = indexer::backfill_handle(db, &ev.did, &handle).await?;
info!(
did = %ev.did,
handle = %handle,
rows_updated = rows,
"backfilled handle on posts (account event)"
);
Ok(()) Ok(())
} }
/// Pull a handle string out of a Jetstream event fragment. Returns
/// `None` if the fragment is absent or doesn't carry a usable
/// `handle` string field.
fn extract_handle(fragment: &Option<Value>) -> Option<String> {
fragment
.as_ref()
.and_then(|v| v.get("handle"))
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
/// Spawn the background task that drains the cursor-flush channel and /// Spawn the background task that drains the cursor-flush channel and
/// writes the running maximum to the DB. Returns when the receiver is /// writes the running maximum to the DB. Returns when the receiver is
/// dropped (i.e. the main process is shutting down). /// dropped (i.e. the main process is shutting down).
+11
View File
@@ -112,11 +112,22 @@ impl HandleSyncWorker {
/// One bounded scan: find up to [`BATCH_SIZE`] distinct DIDs whose /// One bounded scan: find up to [`BATCH_SIZE`] distinct DIDs whose
/// posts have an empty handle, resolve them, and update the rows /// posts have an empty handle, resolve them, and update the rows
/// where the handle is still empty (race-safe). /// where the handle is still empty (race-safe).
///
/// **SQL-level filter**: we exclude `did:key:` entirely because
/// there's no resolver path for them — the PLC directory and the
/// `did:web:` HTTPS resolver both reject non-`did:plc:` /
/// non-`did:web:` DIDs with `Ok(None)`. Previously the worker
/// picked via `ORDER BY did LIMIT 100`, but lexicographically
/// `did:key:` sorts before `did:plc:` / `did:web:`, so the worker
/// would process the same 100 `did:key:` rows every 300 s and
/// never reach any resolvable DID. Filtering at SQL time makes
/// every batch contribute real work.
pub async fn run_once(&self) -> Result<SyncReport> { pub async fn run_once(&self) -> Result<SyncReport> {
let dids: Vec<(String,)> = sqlx::query_as( let dids: Vec<(String,)> = sqlx::query_as(
r#"SELECT DISTINCT did r#"SELECT DISTINCT did
FROM posts FROM posts
WHERE handle = '' WHERE handle = ''
AND (did LIKE 'did:plc:%' OR did LIKE 'did:web:%')
ORDER BY did ORDER BY did
LIMIT $1"#, LIMIT $1"#,
) )
+52 -5
View File
@@ -214,12 +214,21 @@ impl PostRow {
/// by sniffing for `$type` (`app.bsky.embed.images` / `.external` / /// by sniffing for `$type` (`app.bsky.embed.images` / `.external` /
/// `.record`). Keeping it as raw JSON means we don't have to mirror /// `.record`). Keeping it as raw JSON means we don't have to mirror
/// every embed variant in Rust. /// every embed variant in Rust.
///
/// `pds_handle` is the optional handle forwarded by the PDS through
/// the `/internal/ingest-commit` payload. Local-PDS users have
/// `did:key:` DIDs that no PLC directory can resolve, so the PDS is
/// the only authoritative source for their handle. Pass `Some(handle)`
/// when you have it; pass `None` (e.g. Jetstream path) and the
/// `upsert_post` COALESCE guard ensures the empty value won't
/// clobber a backfilled handle from `firehose::handle_identity`.
pub fn from_record( pub fn from_record(
did: &str, did: &str,
rkey: &str, rkey: &str,
collection: &str, collection: &str,
cid: &str, cid: &str,
record: &Value, record: &Value,
pds_handle: Option<&str>,
) -> Self { ) -> Self {
let text = record let text = record
.get("text") .get("text")
@@ -250,10 +259,14 @@ impl PostRow {
.collect::<Vec<_>>() .collect::<Vec<_>>()
}); });
let uri = format!("at://{did}/{collection}/{rkey}"); let uri = format!("at://{did}/{collection}/{rkey}");
let handle = pds_handle
.map(|h| h.trim().to_string())
.filter(|h| !h.is_empty())
.unwrap_or_default();
Self { Self {
uri, uri,
did: did.to_string(), did: did.to_string(),
handle: String::new(), handle,
rkey: rkey.to_string(), rkey: rkey.to_string(),
collection: collection.to_string(), collection: collection.to_string(),
text, text,
@@ -575,12 +588,17 @@ pub async fn apply_commit(
})?; })?;
let cid = op.cid.clone().unwrap_or_default(); let cid = op.cid.clone().unwrap_or_default();
let record = op.record.clone().unwrap_or(Value::Null); let record = op.record.clone().unwrap_or(Value::Null);
// Jetstream `commit` events don't carry the
// handle — leave it empty so the upsert
// COALESCE guard preserves the row's existing
// (or backfilled-from-identity) handle.
let row = PostRow::from_record( let row = PostRow::from_record(
&ev.did, &ev.did,
&rkey, &rkey,
&collection, &collection,
&cid, &cid,
&record, &record,
None,
); );
upsert_post(db, &row).await?; upsert_post(db, &row).await?;
applied = true; applied = true;
@@ -825,7 +843,7 @@ mod tests {
] ]
} }
}); });
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec); let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec, None);
let embed = row.embed.expect("embed must be captured"); let embed = row.embed.expect("embed must be captured");
assert_eq!(embed["$type"], "app.bsky.embed.images"); assert_eq!(embed["$type"], "app.bsky.embed.images");
assert_eq!(embed["images"][0]["alt"], "a cat"); assert_eq!(embed["images"][0]["alt"], "a cat");
@@ -845,7 +863,7 @@ mod tests {
} }
} }
}); });
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec); let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec, None);
let embed = row.embed.expect("embed must be captured"); let embed = row.embed.expect("embed must be captured");
assert_eq!(embed["$type"], "app.bsky.embed.external"); assert_eq!(embed["$type"], "app.bsky.embed.external");
assert_eq!(embed["external"]["uri"], "https://example.com"); assert_eq!(embed["external"]["uri"], "https://example.com");
@@ -857,7 +875,7 @@ mod tests {
"text": "no embed here", "text": "no embed here",
"createdAt": "2026-07-01T12:00:00Z" "createdAt": "2026-07-01T12:00:00Z"
}); });
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec); let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec, None);
assert!(row.embed.is_none()); assert!(row.embed.is_none());
} }
@@ -871,7 +889,7 @@ mod tests {
"root": {"uri": "at://did:plc:b/app.twi.post/r", "cid": "cr"} "root": {"uri": "at://did:plc:b/app.twi.post/r", "cid": "cr"}
} }
}); });
let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec); let row = PostRow::from_record("did:plc:abc", "rkey", "app.bsky.feed.post", "cid", &rec, None);
assert_eq!(row.parent_uri.as_deref(), Some("at://did:plc:b/app.twi.post/p")); assert_eq!(row.parent_uri.as_deref(), Some("at://did:plc:b/app.twi.post/p"));
assert_eq!(row.root_uri.as_deref(), Some("at://did:plc:b/app.twi.post/r")); assert_eq!(row.root_uri.as_deref(), Some("at://did:plc:b/app.twi.post/r"));
} }
@@ -1000,6 +1018,7 @@ mod tests {
"app.twi.post", "app.twi.post",
"cid-embed", "cid-embed",
&record, &record,
None,
); );
upsert_post(&db, &row).await.unwrap(); upsert_post(&db, &row).await.unwrap();
@@ -1092,3 +1111,31 @@ mod tests {
assert_eq!(count, 0); assert_eq!(count, 0);
} }
} }
/// Backfill the `posts.handle` column for every row belonging to
/// `did`. Used by the Jetstream `identity` handler when Jetstream
/// tells us a DID's handle has changed — every existing post row
/// needs the new value.
///
/// **Race-safety**: this only writes if the row's current handle is
/// empty OR doesn't match, so concurrent PDS pushes (which carry
/// the same handle) and concurrent identity replays don't fight.
/// The `WHERE handle IS DISTINCT FROM $1` makes the update a
/// no-op when the value is already correct, which Postgres treats
/// cheaply.
///
/// Returns the number of rows updated.
pub async fn backfill_handle(
db: &PgPool,
did: &str,
new_handle: &str,
) -> Result<u64> {
let res = sqlx::query(
"UPDATE posts SET handle = $1 WHERE did = $2 AND handle IS DISTINCT FROM $1",
)
.bind(new_handle)
.bind(did)
.execute(db)
.await?;
Ok(res.rows_affected())
}
+8
View File
@@ -30,6 +30,13 @@ use tracing::{info, warn};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct IngestCommitReq { pub struct IngestCommitReq {
pub did: String, pub did: String,
/// The poster's current handle, as known by the PDS `users` table.
/// Optional in the wire payload — the AppView falls back to an
/// empty string, and the upsert COALESCE guard prevents the empty
/// value from clobbering a backfilled handle from the Jetstream
/// `identity` event path.
#[serde(default)]
pub handle: Option<String>,
pub collection: String, pub collection: String,
pub action: String, pub action: String,
pub rkey: String, pub rkey: String,
@@ -127,6 +134,7 @@ async fn apply(
&req.collection, &req.collection,
&cid, &cid,
&record, &record,
req.handle.as_deref(),
); );
indexer::upsert_post(&state.db, &row).await.map_err(db_err)?; indexer::upsert_post(&state.db, &row).await.map_err(db_err)?;
Ok(true) Ok(true)
+18 -7
View File
@@ -355,13 +355,24 @@ async fn resolve_profile(
}; };
let Some(target_did) = target_did else { let Some(target_did) = target_did else {
return Err(( // No posts indexed for this handle yet — typical for
StatusCode::NOT_FOUND, // local-PDS users whose posts haven't been ingested into
Json(json!({ // the Jetstream yet (and the AppView indexes only ingested
"error": "NotFound", // posts, never queries upstream PDSs for handle→DID
"message": "no DID or handle provided, or no posts for that handle", // resolution). Instead of bubbling a 404 to the UI which
})), // then shows an error toast instead of an empty profile,
)); // synthesise a profile row with the requested handle and
// zero counts. The `did` is left empty; the UI's
// `displayHandle()` falls back to the handle and the
// "copy did" button just copies an empty string.
let display = handle_clean.unwrap_or_default();
return Ok(Json(ProfileResponse {
did: String::new(),
handle: display,
posts: Vec::new(),
followers: 0,
following: 0,
}));
}; };
// Fetch the user's most recent posts (newest first). We return up to // Fetch the user's most recent posts (newest first). We return up to
+21 -1
View File
@@ -32,6 +32,11 @@ use std::time::Duration;
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct IngestCommitBody<'a> { struct IngestCommitBody<'a> {
did: &'a str, did: &'a str,
/// The poster's current handle. Optional in the wire payload —
/// the AppView's indexer treats an empty/missing handle as the
/// existing empty-string placeholder, which the Jetstream
/// `identity` event path will eventually backfill.
handle: Option<&'a str>,
collection: &'a str, collection: &'a str,
action: &'a str, action: &'a str,
rkey: &'a str, rkey: &'a str,
@@ -63,6 +68,14 @@ impl AppViewPushClient {
/// AT-Protocol record value as JSON — the AppView's indexer reads /// AT-Protocol record value as JSON — the AppView's indexer reads
/// `embed` / `reply` off it, which is why we can't just send the CID. /// `embed` / `reply` off it, which is why we can't just send the CID.
/// ///
/// `handle` is the poster's current handle. Pass `Some(handle)` for
/// local-PDS users so the AppView's `posts.handle` column is
/// populated immediately (otherwise the timeline renders handles as
/// `@did:plc:…` snippets and the profile endpoint can't resolve
/// `handle → did`). For the `app.bsky.feed.like` / `app.bsky.feed.repost`
/// collections the AppView also needs it so the liker's handle
/// lands on the `likes.liker_handle` column.
///
/// Returns `Ok(true)` if the AppView applied the commit, `Ok(false)` /// Returns `Ok(true)` if the AppView applied the commit, `Ok(false)`
/// if it returned a non-2xx status (logged as warn), and `Err(_)` if /// if it returned a non-2xx status (logged as warn), and `Err(_)` if
/// the request itself failed. The caller should treat any non-Ok as /// the request itself failed. The caller should treat any non-Ok as
@@ -70,6 +83,7 @@ impl AppViewPushClient {
pub async fn push_create( pub async fn push_create(
&self, &self,
did: &str, did: &str,
handle: Option<&str>,
collection: &str, collection: &str,
rkey: &str, rkey: &str,
cid: &str, cid: &str,
@@ -77,6 +91,7 @@ impl AppViewPushClient {
) -> Result<bool> { ) -> Result<bool> {
self.push( self.push(
did, did,
handle,
collection, collection,
"create", "create",
rkey, rkey,
@@ -93,19 +108,21 @@ impl AppViewPushClient {
collection: &str, collection: &str,
rkey: &str, rkey: &str,
) -> Result<bool> { ) -> Result<bool> {
self.push(did, collection, "delete", rkey, None, None, None) self.push(did, None, collection, "delete", rkey, None, None, None)
.await .await
} }
pub async fn push_follow_create( pub async fn push_follow_create(
&self, &self,
did: &str, did: &str,
handle: Option<&str>,
rkey: &str, rkey: &str,
subject_did: &str, subject_did: &str,
record: &Value, record: &Value,
) -> Result<bool> { ) -> Result<bool> {
self.push( self.push(
did, did,
handle,
"app.bsky.graph.follow", "app.bsky.graph.follow",
"create", "create",
rkey, rkey,
@@ -124,6 +141,7 @@ impl AppViewPushClient {
) -> Result<bool> { ) -> Result<bool> {
self.push( self.push(
did, did,
None,
"app.bsky.graph.follow", "app.bsky.graph.follow",
"delete", "delete",
rkey, rkey,
@@ -137,6 +155,7 @@ impl AppViewPushClient {
async fn push( async fn push(
&self, &self,
did: &str, did: &str,
handle: Option<&str>,
collection: &str, collection: &str,
action: &str, action: &str,
rkey: &str, rkey: &str,
@@ -147,6 +166,7 @@ impl AppViewPushClient {
let url = format!("{}/internal/ingest-commit", self.base_url); let url = format!("{}/internal/ingest-commit", self.base_url);
let body = IngestCommitBody { let body = IngestCommitBody {
did, did,
handle,
collection, collection,
action, action,
rkey, rkey,
+3 -1
View File
@@ -17,7 +17,7 @@
//! removed from the MST, a new commit is signed, the AppView is //! removed from the MST, a new commit is signed, the AppView is
//! told to drop the row, and we return the new commit CID + rev. //! told to drop the row, and we return the new commit CID + rev.
use crate::routes::helpers::{apply_repo_write, err, to_sqlx_error, RepoWriteOutcome}; use crate::routes::helpers::{apply_repo_write, err, lookup_handle, to_sqlx_error, RepoWriteOutcome};
use at_repo::blockstore::Blockstore; use at_repo::blockstore::Blockstore;
use crate::routes::types::ErrorBody; use crate::routes::types::ErrorBody;
use crate::state::AppState; use crate::state::AppState;
@@ -273,6 +273,7 @@ pub async fn create_like(
let value_cid_str = value_cid.to_string(); let value_cid_str = value_cid.to_string();
let push_record = record.clone(); let push_record = record.clone();
let push_rkey = rkey.clone(); let push_rkey = rkey.clone();
let push_handle_str: Option<String> = lookup_handle(&state, &did).await;
let commit = apply_and_commit(&state, &did, move |repo| { let commit = apply_and_commit(&state, &did, move |repo| {
let value_cid = value_cid; let value_cid = value_cid;
@@ -317,6 +318,7 @@ pub async fn create_like(
if let Err(e) = push_handle if let Err(e) = push_handle
.push_create( .push_create(
&push_did, &push_did,
push_handle_str.as_deref(),
LIKE_COLLECTION, LIKE_COLLECTION,
&push_rkey_owned, &push_rkey_owned,
&push_cid_owned, &push_cid_owned,
+22 -1
View File
@@ -453,4 +453,25 @@ async fn persist_user_blocks_in_tx(
})?; })?;
} }
Ok(()) Ok(())
} }
/// Look up the current handle for `did` from the `users` table.
///
/// Returned as `Option<String>` (rather than an empty default) so the
/// caller can decide what to do when the row hasn't been found yet —
/// in practice the row is always present for an authenticated route,
/// but we'd rather log a warning than silently emit a bogus empty
/// handle into the AppView's `posts.handle` column.
///
/// Called once per `createRecord` / `feed.like.create` write, just
/// before the AppView push, so the liker's/poster's display handle
/// lands on the AppView row at write time — otherwise the AppView has
/// no source for `did:key:` handles and the timeline renders them as
/// `@did:key:z16D…` snippets.
pub async fn lookup_handle(state: &AppState, did: &str) -> Option<String> {
sqlx::query_scalar::<_, String>("SELECT handle FROM users WHERE did = $1")
.bind(did)
.fetch_optional(&state.db)
.await
.ok()
.flatten()
}
+19 -2
View File
@@ -1,5 +1,5 @@
use crate::routes::helpers::{ use crate::routes::helpers::{
apply_repo_write, err, to_sqlx_error, RepoWriteOutcome, apply_repo_write, err, lookup_handle, to_sqlx_error, RepoWriteOutcome,
}; };
use crate::routes::types::{CreateRecordReq, CreateRecordResp}; use crate::routes::types::{CreateRecordReq, CreateRecordResp};
use crate::state::AppState; use crate::state::AppState;
@@ -92,6 +92,16 @@ pub async fn create_record(
let push_rkey = rkey.clone(); let push_rkey = rkey.clone();
let push_cid = value_cid.to_string(); let push_cid = value_cid.to_string();
let push_record = req.record.clone(); let push_record = req.record.clone();
// Resolve the poster's current handle from the local users
// table *before* the spawn — the closure can't easily borrow
// `&state` after we hand ownership to the spawned task.
let push_handle_str: Option<String> = match lookup_handle(&state, &did).await {
h @ Some(_) => h,
None => {
tracing::warn!(did = %did, "appview push: no handle in users table; timeline will show @did-prefix");
None
}
};
let collection = req.collection.clone(); let collection = req.collection.clone();
let outcome = apply_repo_write(&state, &did, move |repo| { let outcome = apply_repo_write(&state, &did, move |repo| {
@@ -136,7 +146,14 @@ pub async fn create_record(
// the AppView. // the AppView.
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = push_handle if let Err(e) = push_handle
.push_create(&push_did, &push_coll, &push_rkey, &push_cid, &push_record) .push_create(
&push_did,
push_handle_str.as_deref(),
&push_coll,
&push_rkey,
&push_cid,
&push_record,
)
.await .await
{ {
tracing::warn!(error = %e, did = %push_did, "appview push_create failed; jetstream will replay"); tracing::warn!(error = %e, did = %push_did, "appview push_create failed; jetstream will replay");
@@ -0,0 +1,17 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Default capabilities for the maarcadetweet main window",
"windows": ["main"],
"permissions": [
"core:default",
"core:event:default",
"core:webview:allow-internal-toggle-devtools",
"core:window:default",
"notification:default",
"shell:default",
"dialog:default",
"updater:default",
"window-state:default"
]
}
+21
View File
@@ -559,6 +559,15 @@ pub fn run() {
use tauri::Manager; use tauri::Manager;
tracing::info!("maarcadetweet starting up"); 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!` // Embed the tray icon at compile time. `include_image!`
// resolves paths relative to `CARGO_MANIFEST_DIR` and // resolves paths relative to `CARGO_MANIFEST_DIR` and
// bakes the raw RGBA pixels into the binary, so the // bakes the raw RGBA pixels into the binary, so the
@@ -606,6 +615,14 @@ pub fn run() {
None::<&str>, None::<&str>,
) )
.map_err(|e| format!("failed to build search menu item: {e}"))?; .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( let quit_item = tauri::menu::MenuItem::with_id(
app, app,
"tray_quit", "tray_quit",
@@ -625,6 +642,7 @@ pub fn run() {
&compose_item, &compose_item,
&profile_item, &profile_item,
&search_item, &search_item,
&settings_item,
&separator, &separator,
&quit_item, &quit_item,
], ],
@@ -653,6 +671,9 @@ pub fn run() {
"tray_search" => { "tray_search" => {
let _ = tauri::Emitter::emit(app, "app://navigate", "search"); let _ = tauri::Emitter::emit(app, "app://navigate", "search");
} }
"tray_settings" => {
let _ = tauri::Emitter::emit(app, "app://navigate", "settings");
}
"tray_quit" => { "tray_quit" => {
app.exit(0); app.exit(0);
} }
+2 -1
View File
@@ -28,7 +28,8 @@
} }
], ],
"security": { "security": {
"csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' ipc: http://ipc.localhost" "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' ipc: http://ipc.localhost",
"capabilities": ["default"]
} }
}, },
"plugins": { "plugins": {
+246 -32
View File
@@ -7,6 +7,8 @@
fetchProfile, fetchProfile,
fetchSearch, fetchSearch,
fetchPost, fetchPost,
openExternalUrl,
showError,
type Session, type Session,
type Post, type Post,
type ProfileResponse, type ProfileResponse,
@@ -19,7 +21,7 @@
import Terminal from "./lib/components/Terminal.svelte"; import Terminal from "./lib/components/Terminal.svelte";
import Skeleton from "./lib/components/Skeleton.svelte"; import Skeleton from "./lib/components/Skeleton.svelte";
type View = "home" | "compose" | "profile" | "search"; type View = "home" | "compose" | "profile" | "search" | "settings";
let view: View = $state("home"); let view: View = $state("home");
let currentUser: Session | null = $state(null); let currentUser: Session | null = $state(null);
@@ -32,7 +34,6 @@
let timelineError: string | null = $state(null); let timelineError: string | null = $state(null);
let seenUris: Set<string> = new Set(); let seenUris: Set<string> = new Set();
let _statusTimer: number | undefined; let _statusTimer: number | undefined;
let _timelinePollTimer: number | undefined;
// Profile state. // Profile state.
let profile: ProfileResponse | null = $state(null); let profile: ProfileResponse | null = $state(null);
@@ -91,7 +92,7 @@
// — `main.ts` is the only place that wires to the Tauri event bus). // — `main.ts` is the only place that wires to the Tauri event bus).
function onNavigateToView(e: Event) { function onNavigateToView(e: Event) {
const detail = (e as CustomEvent<{ view: View }>).detail; const detail = (e as CustomEvent<{ view: View }>).detail;
if (detail?.view) view = detail.view; if (detail?.view) setView(detail.view);
} }
function onNotification(e: Event) { function onNotification(e: Event) {
const detail = (e as CustomEvent<{ title: string; body: string; url: string | null }>).detail; const detail = (e as CustomEvent<{ title: string; body: string; url: string | null }>).detail;
@@ -131,7 +132,7 @@
} }
} else { } else {
// unknown scheme — just open home // unknown scheme — just open home
view = "home"; setView("home");
} }
} }
@@ -184,7 +185,7 @@
registerCleanup(() => { registerCleanup(() => {
if (_sessionUnsub) _sessionUnsub(); if (_sessionUnsub) _sessionUnsub();
if (_statusTimer) clearInterval(_statusTimer); if (_statusTimer) clearInterval(_statusTimer);
if (_timelinePollTimer) clearInterval(_timelinePollTimer); if (_pollTimer != null) clearInterval(_pollTimer);
if (_searchDebounce) clearTimeout(_searchDebounce); if (_searchDebounce) clearTimeout(_searchDebounce);
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.removeEventListener("maarcadetweet:toast", _toastHandler); window.removeEventListener("maarcadetweet:toast", _toastHandler);
@@ -199,6 +200,11 @@
_sessionUnsub = session.subscribe((s) => { _sessionUnsub = session.subscribe((s) => {
currentUser = s; currentUser = s;
status = { ...status, did: s?.did, handle: s?.handle, authenticated: !!s }; status = { ...status, did: s?.did, handle: s?.handle, authenticated: !!s };
// Drive the 5s poll off the session lifecycle instead of a
// reactive effect — the effect form kept tripping Svelte 5's
// depth guard.
if (s) startPoll();
else stopPoll();
}); });
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@@ -219,29 +225,48 @@
})(); })();
}); });
// Re-fetch the home timeline whenever we navigate to "home" or // Imperative view-switching. We dispatch from a single function
// when the logged-in user changes. We also poll every 5s while // (called by NavRail on_select, the LoginScreen onLogin path, and
// the home view is active so new posts trickle in. // the tray-event bridge) so every view transition runs the same
$effect(() => { // side effects in one place. Previously this was four separate
if (view === "home" && currentUser) { // `$effect` blocks that read `view` / `currentUser` and called
// `refreshTimeline` / `refreshProfile` / `scheduleSearch`. Svelte
// 5's depth tracker kept aborting with `effect_update_depth_exceeded`
// because the sync portions of those refresh functions (`timelineLoading
// = true`, `profileLoading = true`) wrote $state that the effect's
// proxy-tracking had flagged as a self-write. Driving everything
// imperatively from a setter sidesteps the reactive cycle.
function setView(next: View) {
const prev = view;
view = next;
if (!currentUser) return;
// Entering home from elsewhere — pull a fresh timeline and
// (re)start the poll timer. Leaving home clears it.
if (next === "home" && prev !== "home") {
void refreshTimeline(true); void refreshTimeline(true);
if (_timelinePollTimer) clearInterval(_timelinePollTimer);
_timelinePollTimer = window.setInterval(() => {
void refreshTimeline(false); // poll = prepend new posts, don't wipe
}, 5000);
} else if (_timelinePollTimer) {
clearInterval(_timelinePollTimer);
_timelinePollTimer = undefined;
} }
if (next === "profile") {
if (view === "profile" && currentUser) { const handle = currentUser.handle;
void refreshProfile(currentUser.handle); void refreshProfile(handle);
} }
if (next === "search" && searchQuery.trim().length > 0) {
if (view === "search" && currentUser && searchQuery.trim().length > 0) {
scheduleSearch(); scheduleSearch();
} }
}); }
let _pollTimer: number | undefined;
function startPoll() {
if (_pollTimer != null) return;
_pollTimer = window.setInterval(() => {
if (view === "home") void refreshTimeline(false);
}, 5000);
}
function stopPoll() {
if (_pollTimer == null) return;
window.clearInterval(_pollTimer);
_pollTimer = undefined;
}
async function refreshTimeline(reset: boolean) { async function refreshTimeline(reset: boolean) {
if (!currentUser) return; if (!currentUser) return;
@@ -351,6 +376,19 @@
await refreshTimeline(true); await refreshTimeline(true);
} }
async function handleLogout() {
try {
await session.logout();
setView("home");
profile = null;
searchResults = [];
threadRoot = null;
threadParent = null;
userPosts = [];
} catch (e) {
showError(`logout failed: ${String(e)}`);
}
}
// Derive a display handle. The session already gives us the user's // Derive a display handle. The session already gives us the user's
// real handle (e.g. "alice.bsky.social"). When the AppView decorates // real handle (e.g. "alice.bsky.social"). When the AppView decorates
// posts that have empty handles it falls back to a synthetic // posts that have empty handles it falls back to a synthetic
@@ -360,6 +398,24 @@
if (h.startsWith("@")) return h; if (h.startsWith("@")) return h;
return `@${h}`; return `@${h}`;
} }
// Mirror the URLs the Rust shell reads from MAARCADETWEET_PDS_URL /
// MAARCADETWEET_APPVIEW_URL (see `crates/tauri-app/src-tauri/src/lib.rs`).
// Used in the Settings view to show which backends the client is
// talking to. Kept as plain helpers so they can be swapped for a
// `pds_describe`/`appview_describe` Tauri command later.
function pdsBase(): string {
if (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_PDS_URL) {
return (import.meta as any).env.VITE_PDS_URL as string;
}
return "http://127.0.0.1:2583";
}
function appviewBase(): string {
if (typeof import.meta !== "undefined" && (import.meta as any).env?.VITE_APPVIEW_URL) {
return (import.meta as any).env.VITE_APPVIEW_URL as string;
}
return "http://127.0.0.1:2584";
}
</script> </script>
{#if !currentUser} {#if !currentUser}
@@ -367,7 +423,7 @@
<LoginScreen <LoginScreen
onLogin={(s) => { onLogin={(s) => {
currentUser = s; currentUser = s;
view = "home"; setView("home");
}} }}
/> />
</div> </div>
@@ -375,9 +431,7 @@
<div class="shell"> <div class="shell">
<NavRail <NavRail
{view} {view}
on_select={(v) => { on_select={(v) => setView(v)}
view = v;
}}
/> />
<div class="main"> <div class="main">
<Terminal title={view === "home" ? "maarcadetweet — home" : `maarcadetweet — ${view}`}> <Terminal title={view === "home" ? "maarcadetweet — home" : `maarcadetweet — ${view}`}>
@@ -446,9 +500,10 @@
{:else if profile} {:else if profile}
<section class="profile"> <section class="profile">
<header class="profile__head"> <header class="profile__head">
<span class="profile__handle">{displayHandle(profile.handle)}</span> <div class="profile__handle">{displayHandle(profile.handle)}</div>
<span class="profile__did" title={profile.did}>{profile.did}</span> <div class="profile__did" title={profile.did}>{profile.did}</div>
</header> </header>
<div class="profile__actions"> <div class="profile__actions">
<button <button
class="btn btn--ghost" class="btn btn--ghost"
@@ -463,7 +518,23 @@
onclick={() => onclick={() =>
copyToClipboard(`at://${profile!.did}/app.twi.post`)} copyToClipboard(`at://${profile!.did}/app.twi.post`)}
>copy at-uri</button> >copy at-uri</button>
<button
class="btn btn--ghost"
type="button"
title="Open profile in your default browser"
onclick={() =>
openExternalUrl(
`https://bsky.app/profile/${profile!.handle}`,
)}
>open in browser</button>
<button
class="btn btn--ghost"
type="button"
title="Sign out of this app"
onclick={handleLogout}
>sign out</button>
</div> </div>
<dl class="counts"> <dl class="counts">
<div> <div>
<dt>followers</dt> <dt>followers</dt>
@@ -478,15 +549,90 @@
<dd>{profile.posts.length}</dd> <dd>{profile.posts.length}</dd>
</div> </div>
</dl> </dl>
{#if profile.posts.length === 0} {#if profile.posts.length === 0}
<div class="empty">// no posts yet</div> <div class="empty">// no posts yet — compose your first one</div>
{:else} {:else}
<h3 class="profile__h3">// recent posts</h3>
{#each profile.posts as p (p.uri)} {#each profile.posts as p (p.uri)}
<PostCard post={p} on_thread_click={openThread} /> <PostCard post={p} on_thread_click={openThread} />
{/each} {/each}
{/if} {/if}
</section> </section>
{/if} {/if}
{:else if view === "settings"}
<div class="head">
<span class="prompt">$</span>
<span class="title">// settings</span>
</div>
<section class="settings">
<h3 class="settings__h3">// account</h3>
<dl class="settings__rows">
<div>
<dt>handle</dt>
<dd>@{currentUser?.handle ?? "?"}</dd>
</div>
<div>
<dt>did</dt>
<dd class="did-cell">{currentUser?.did ?? "?"}</dd>
</div>
<div>
<dt>posts in cache</dt>
<dd>{userPosts.length}</dd>
</div>
</dl>
<h3 class="settings__h3">// actions</h3>
<div class="settings__actions">
<button
class="btn btn--ghost"
type="button"
onclick={() =>
currentUser && copyToClipboard(currentUser.did)}
>copy my did</button>
<button
class="btn btn--ghost"
type="button"
onclick={() =>
openExternalUrl(
`https://bsky.app/profile/${currentUser?.handle ?? ""}`,
)}
>open profile in browser</button>
<button
class="btn btn--ghost"
type="button"
onclick={() => setView("home")}
>← back to timeline</button>
</div>
<h3 class="settings__h3">// about</h3>
<dl class="settings__rows">
<div>
<dt>app</dt>
<dd>maarcadetweet</dd>
</div>
<div>
<dt>version</dt>
<dd>0.1.0</dd>
</div>
<div>
<dt>backend</dt>
<dd>{pdsBase()}</dd>
</div>
<div>
<dt>appview</dt>
<dd>{appviewBase()}</dd>
</div>
</dl>
<div class="settings__signout">
<button
class="btn btn--ghost btn--danger"
type="button"
onclick={handleLogout}
>sign out</button>
</div>
</section>
{:else if view === "search"} {:else if view === "search"}
<div class="head"> <div class="head">
<span class="prompt">$</span> <span class="prompt">$</span>
@@ -595,8 +741,11 @@
"rail status"; "rail status";
min-height: 0; min-height: 0;
} }
.shell > :global(nav.rail) { grid-area: rail; } /* NavRail and StatusBar self-assign their own grid-area
.shell > :global(.statusbar) { grid-area: status; } (`grid-area: rail` / `grid-area: status`) in their component
styles, so the parent doesn't need any :global() child
selectors. The `.main` slot is just the next sibling; we set
its grid-area explicitly below. */
.main { .main {
grid-area: main; grid-area: main;
overflow: auto; overflow: auto;
@@ -753,4 +902,69 @@
border-color: var(--red); border-color: var(--red);
background: rgba(255, 59, 48, 0.08); background: rgba(255, 59, 48, 0.08);
} }
.btn--danger {
color: var(--red);
border-color: var(--red);
}
.btn--danger:hover:not(:disabled) {
background: rgba(255, 59, 48, 0.08);
color: var(--red);
border-color: var(--red);
}
.profile__h3,
.settings__h3 {
font-family: var(--font-mono);
font-size: var(--fs-50);
color: var(--text-dim);
letter-spacing: 0.04em;
margin: var(--s-4) 0 var(--s-2);
font-weight: 400;
}
.did-cell {
word-break: break-all;
font-size: var(--fs-50);
}
.settings {
padding: 0 var(--s-3);
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.settings__rows {
display: flex;
flex-direction: column;
gap: var(--s-1);
padding: var(--s-2) var(--s-4);
margin: 0 0 var(--s-4);
font-family: var(--font-mono);
font-size: var(--fs-50);
}
.settings__rows > div {
display: flex;
gap: var(--s-3);
}
.settings__rows dt {
color: var(--text-dim);
letter-spacing: 0.04em;
min-width: 9rem;
}
.settings__rows dd {
margin: 0;
color: var(--text);
}
.settings__actions {
display: flex;
flex-wrap: wrap;
gap: var(--s-2);
margin: 0 0 var(--s-4);
}
.settings__signout {
margin-top: var(--s-4);
padding-top: var(--s-4);
border-top: 1px dashed var(--line);
}
</style> </style>
+57 -3
View File
@@ -30,12 +30,40 @@ async function tauriCall<T>(cmd: string, fallback: T, args?: Record<string, unkn
* the browser preview this throws a friendly Error so the UI can * the browser preview this throws a friendly Error so the UI can
* show a "running in browser preview" notice. In the Tauri * show a "running in browser preview" notice. In the Tauri
* webview it falls through to a normal `invoke` call. * 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<T>(cmd: string, args?: Record<string, unknown>): Promise<T> { async function safeInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
if (!isTauri()) { if (!isTauri()) {
throw new Error(`Tauri command ${cmd} requires the desktop runtime`); throw new Error(`Tauri command ${cmd} requires the desktop runtime`);
} }
return invoke<T>(cmd, args); try {
return await invoke<T>(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<T>(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 = { export type Session = {
@@ -47,9 +75,35 @@ export type Session = {
function createSessionStore() { function createSessionStore() {
const { subscribe, set } = writable<Session | null>(null); const { subscribe, set } = writable<Session | null>(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<Session | null> | null = null;
return { return {
subscribe, 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<Session | null> {
if (pendingRefresh) return pendingRefresh;
pendingRefresh = (async () => {
try {
const s = await invoke<Session>("auth_refresh");
set(s);
return s;
} catch (e) {
console.warn("session refresh failed", e);
return null;
} finally {
pendingRefresh = null;
}
})();
return pendingRefresh;
},
async load() { async load() {
const s = await tauriCall<Session | null>("current_session", null); const s = await tauriCall<Session | null>("current_session", null);
set(s); set(s);
@@ -355,13 +409,13 @@ export async function showNotification(
/// The handler receives the event payload (empty object for these /// The handler receives the event payload (empty object for these
/// cases). Returns an unsubscribe function. /// cases). Returns an unsubscribe function.
export async function listenTrayEvents( export async function listenTrayEvents(
handler: (event: "show" | "home" | "compose" | "profile" | "search") => void, handler: (event: "show" | "home" | "compose" | "profile" | "search" | "settings") => void,
): Promise<() => void> { ): Promise<() => void> {
const { listen } = await import("@tauri-apps/api/event"); const { listen } = await import("@tauri-apps/api/event");
const unlisteners: Array<() => void> = []; const unlisteners: Array<() => void> = [];
const u1 = await listen("app://show", () => handler("show")); const u1 = await listen("app://show", () => handler("show"));
const u2 = await listen("app://navigate", (e) => { const u2 = await listen("app://navigate", (e) => {
handler(e.payload as "home" | "profile" | "search"); handler(e.payload as "home" | "profile" | "search" | "settings");
}); });
const u3 = await listen("app://compose", () => handler("compose")); const u3 = await listen("app://compose", () => handler("compose"));
unlisteners.push(u1, u2, u3); unlisteners.push(u1, u2, u3);
@@ -4,7 +4,7 @@
// `$bindable`, use a callback prop to bubble state changes up to // `$bindable`, use a callback prop to bubble state changes up to
// the parent. // the parent.
type View = "home" | "compose" | "profile" | "search"; type View = "home" | "compose" | "profile" | "search" | "settings";
let { let {
view = "home", view = "home",
@@ -19,6 +19,7 @@
{ id: "compose", label: "compose", key: "c", icon: "compose" }, { id: "compose", label: "compose", key: "c", icon: "compose" },
{ id: "profile", label: "profile", key: "p", icon: "profile" }, { id: "profile", label: "profile", key: "p", icon: "profile" },
{ id: "search", label: "search", key: "/", icon: "search" }, { id: "search", label: "search", key: "/", icon: "search" },
{ id: "settings", label: "settings", key: ",", icon: "settings" },
]; ];
</script> </script>
@@ -44,6 +45,11 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 4-7 8-7s8 3 8 7"/> <circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 4-7 8-7s8 3 8 7"/>
</svg> </svg>
{:else if item.icon === "settings"}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3h.1a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v.1a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>
</svg>
{:else} {:else}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/> <circle cx="11" cy="11" r="7"/><path d="m20 20-3-3"/>
@@ -56,7 +62,15 @@
</nav> </nav>
<style> <style>
/* Self-assign the grid area so the parent App.svelte doesn't
need a `:global(nav.rail)` selector. Doing it via a parent
child-selector is fragile under Svelte 5's scoping — the
parent's `.shell.s-XXX > nav.rail` doesn't reliably match
`<nav class="rail s-YYY">` in the Tauri webview, which leaves
the buttons invisible to clicks. Owning the grid placement
here avoids the cross-component selector entirely. */
.rail { .rail {
grid-area: rail;
width: 88px; width: 88px;
background: var(--bg); background: var(--bg);
border-right: 1px solid var(--line); border-right: 1px solid var(--line);
@@ -29,10 +29,10 @@ async function mountHarness() {
} }
describe("NavRail (callback-prop pattern)", () => { describe("NavRail (callback-prop pattern)", () => {
it("renders 4 buttons with home active", async () => { it("renders 5 buttons with home active", async () => {
await mountHarness(); await mountHarness();
const btns = target.querySelectorAll("button.rail__btn"); const btns = target.querySelectorAll("button.rail__btn");
expect(btns.length).toBe(4); expect(btns.length).toBe(5);
expect(btns[0].classList.contains("active")).toBe(true); expect(btns[0].classList.contains("active")).toBe(true);
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("home"); expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("home");
}); });
@@ -56,5 +56,10 @@ describe("NavRail (callback-prop pattern)", () => {
await tick(); await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("profile"); expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("profile");
expect(btns[2].classList.contains("active")).toBe(true); expect(btns[2].classList.contains("active")).toBe(true);
btns[4].dispatchEvent(new MouseEvent("click", { bubbles: true }));
await tick();
expect(target.querySelector('[data-testid="view-value"]')?.textContent).toBe("settings");
expect(btns[4].classList.contains("active")).toBe(true);
}); });
}); });
@@ -5,7 +5,7 @@
import NavRail from "./NavRail.svelte"; import NavRail from "./NavRail.svelte";
type View = "home" | "compose" | "profile" | "search"; type View = "home" | "compose" | "profile" | "search" | "settings";
let view: View = $state("home"); let view: View = $state("home");
</script> </script>
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte";
import { import {
fetchPost, fetchPost,
likePost, likePost,
@@ -23,13 +24,24 @@
let quotedErr: string | null = $state(null); let quotedErr: string | null = $state(null);
let quotedLoading: boolean = $state(false); let quotedLoading: boolean = $state(false);
// Resolve the embedded `app.bsky.embed.record` (a quoted post) by
// fetching the full record once per URI. We `untrack()` the
// in-flight check (`quoted` / `quotedLoading`) so a sync read+write
// of the same $state isn't reported as
// `effect_update_depth_exceeded` — without it, every time the
// effect re-fires (e.g. on parent re-render) Svelte 5's depth
// tracker saw `quotedLoading` read **and** flipped to `true`
// within the same tick.
$effect(() => { $effect(() => {
const rec = (post.embed?.$type === "app.bsky.embed.record" || post.embed?.$type === "app.bsky.embed.recordWithMedia") const rec = (post.embed?.$type === "app.bsky.embed.record" || post.embed?.$type === "app.bsky.embed.recordWithMedia")
? post.embed?.record ? post.embed?.record
: null; : null;
if (rec?.uri && !quoted && !quotedLoading) { const targetUri = rec?.uri;
if (!targetUri) return;
untrack(() => {
if (quoted || quotedLoading) return;
quotedLoading = true; quotedLoading = true;
fetchPost(rec.uri) fetchPost(targetUri)
.then((r) => { .then((r) => {
quoted = r.post; quoted = r.post;
}) })
@@ -39,7 +51,7 @@
.finally(() => { .finally(() => {
quotedLoading = false; quotedLoading = false;
}); });
} });
}); });
// Resolve the embed shape once at render time. We sniff $type to // Resolve the embed shape once at render time. We sniff $type to
@@ -99,13 +111,26 @@
$state(null); $state(null);
$effect.pre(() => { $effect.pre(() => {
const k = localStorageKey(`liked:${post.did}:${post.rkey}`); const k = localStorageKey(`liked:${post.did}:${post.rkey}`);
likedBox = useLocalStorage<{ liked: boolean; uri: string | null }>(k, { // Re-create the box whenever the post changes; the hydration
liked: false, // reads (`likedBox.get()`) and the writes that seed `liked` /
uri: null, // `likedUri` from localStorage all happen inside `untrack` so
// `likedBox` (which is $state) is **read and written in the same
// effect run**. Without untrack, Svelte 5's effect tracker would
// schedule `possible_effect_self_invalidation` on `likedBox`
// and the effect would loop until `effect_update_depth_exceeded`
// fires. See the explorer agent's read-out: this is THE loop
// that took down login with `process_fn x 95`. The outer
// `post.did` / `post.rkey` reads remain tracked so the effect
// still re-runs when navigating from one card to the next.
untrack(() => {
likedBox = useLocalStorage<{ liked: boolean; uri: string | null }>(k, {
liked: false,
uri: null,
});
const stored = likedBox.get();
liked = stored.liked;
likedUri = stored.uri;
}); });
const stored = likedBox.get();
liked = stored.liked;
likedUri = stored.uri;
}); });
$effect(() => { $effect(() => {
if (!likedBox) return; if (!likedBox) return;
@@ -53,7 +53,11 @@
</div> </div>
<style> <style>
/* Self-assign the grid area — see NavRail.svelte for why we don't
rely on the parent's `:global(.statusbar)` child selector
(Svelte 5 scoping makes it unreliable in the Tauri webview). */
.statusbar { .statusbar {
grid-area: status;
height: 24px; height: 24px;
background: var(--bg-elev); background: var(--bg-elev);
border-top: 1px solid var(--line); border-top: 1px solid var(--line);