fix(tauri-app): Token an die AppView senden — und die Erneuerung reparieren

Die vier viewer-bezogenen AppView-Aufrufe (Timeline, Notifications,
Count, Seen) senden jetzt das Access-JWT. Ohne Session gibt es einen
sprechenden Fehler statt eines leeren Bearer-Headers.

Dabei kam heraus, dass die automatische Token-Erneuerung noch nie
funktioniert hat: isTokenInvalid() stieg mit `typeof e !== "object"`
sofort aus, aber Tauri lehnt bei Commands mit Result<T, String> mit
einem blanken String ab — der Zweig war seit seiner Einführung tot.
Belegt per Mutationstest: mit der alten Zeile fallen acht der neuen
Tests um. Die Prüfung liest den Fehlertext jetzt über einen Helfer,
der Strings und Objekte behandelt.

Dazu: der Badge-Poll bricht ab, wenn die Erneuerung endgültig
scheitert, statt weiter gegen einen 401 zu laufen. 503 AuthUnavailable
gilt dabei bewusst nicht als Auth-Fehler — die PDS kann kurz weg sein,
der Poll soll das überdauern.

Co-Authored-By: Eifelduds <noreply@eifelduds.de>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
This commit is contained in:
tomdebone
2026-09-09 23:03:12 +02:00
co-authored by Eifelduds
parent 2b5df6472e
commit 16a0736b46
7 changed files with 903 additions and 105 deletions
+30 -4
View File
@@ -354,6 +354,28 @@ async fn unfollow_user(
}))
}
/// Access JWT for the AppView's viewer-scoped endpoints.
///
/// The four authenticated AppView calls (`timeline_home`,
/// `fetch_notifications`, `notification_count`,
/// `mark_notifications_seen`) all need the same thing: the stored
/// session's access JWT, or a message the UI can render when there
/// isn't one. Factored out so no call site can accidentally send an
/// empty `Authorization: Bearer` header — which the AppView would
/// answer with a 401 `TokenInvalid`, and the frontend would then burn a
/// pointless refresh round trip on before failing anyway.
///
/// The message deliberately contains neither `TokenInvalid` nor
/// `ExpiredSignature`: `safeInvoke`'s `isTokenInvalid()` greps for those
/// substrings, and a logged-out client has nothing to refresh *with*.
fn require_access_jwt(state: &AppState, what: &str) -> Result<String, String> {
state
.store
.load()
.map(|s| s.access_jwt)
.ok_or_else(|| format!("not logged in: {what} requires a signed-in session"))
}
#[tauri::command]
async fn timeline_home(
state: tauri::State<'_, AppState>,
@@ -362,9 +384,10 @@ async fn timeline_home(
limit: Option<u32>,
) -> Result<appview_client::TimelineResponse, String> {
let lim = limit.unwrap_or(30).clamp(1, 100);
let jwt = require_access_jwt(&state, "the home timeline")?;
state
.appview
.fetch_timeline(&did, cursor.as_deref(), lim)
.fetch_timeline(&did, cursor.as_deref(), lim, &jwt)
.await
.map_err(|e| e.to_string())
}
@@ -460,9 +483,10 @@ async fn fetch_notifications(
limit: Option<u32>,
) -> Result<appview_client::NotificationsResponse, String> {
let lim = limit.unwrap_or(30).clamp(1, 100);
let jwt = require_access_jwt(&state, "notifications")?;
state
.appview
.fetch_notifications(&did, cursor.as_deref(), lim)
.fetch_notifications(&did, cursor.as_deref(), lim, &jwt)
.await
.map_err(|e| e.to_string())
}
@@ -473,9 +497,10 @@ async fn notification_count(
state: tauri::State<'_, AppState>,
did: String,
) -> Result<appview_client::NotificationCountResponse, String> {
let jwt = require_access_jwt(&state, "the unread-notification count")?;
state
.appview
.notification_count(&did)
.notification_count(&did, &jwt)
.await
.map_err(|e| e.to_string())
}
@@ -491,9 +516,10 @@ async fn mark_notifications_seen(
did: String,
seen_at: Option<String>,
) -> Result<appview_client::NotificationSeenResponse, String> {
let jwt = require_access_jwt(&state, "marking notifications seen")?;
state
.appview
.mark_notifications_seen(&did, seen_at.as_deref())
.mark_notifications_seen(&did, seen_at.as_deref(), &jwt)
.await
.map_err(|e| e.to_string())
}