An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: address PR review feedback for MM-150 home screen

Critical:
- Add tracing::error\! to OAuthClient::new failure arm in home.rs
- Add tracing::error\! to getSession Err arm in home.rs
- Add INVALID_GRANT to OAuthError variant list in CLAUDE.md

Important:
- Log warn for non-ItemNotFound Keychain errors in log_out
- Add tracing::warn\! to oauth_error_code() UNKNOWN fallback
- Add .catch() to loadHomeData() in ipc.ts to enforce never-rejects contract
- Add try/catch to loadData() in HomeScreen.svelte (prevents infinite spinner)
- Add try/catch to handleLogOut() in HomeScreen.svelte
- Show 'Failed' state in copyDid() and copyKey() instead of console.error
- Remove AC/ticket references from home.rs test comments
- Fix DoD item 4 in design plan (get_session/check_health → load_home_data/log_out)

authored by

Malpercio and committed by
Tangled
1688dd11 2461a175

+49 -23
+1 -1
apps/identity-wallet/CLAUDE.md
··· 49 49 - `load_home_data` always returns Ok -- partial failures (relay unreachable, session expired) are encoded as `HomeData` fields (`relay_healthy: false`, `session: null`, `session_error: "NOT_AUTHENTICATED"`) so the UI can render whatever is available 50 50 - `log_out` always returns Ok -- Keychain delete errors are swallowed; the frontend unconditionally navigates to the welcome screen; device key and DPoP key are deliberately preserved (not deleted) 51 51 - `HomeData` and `SessionInfo` serialize with `#[serde(rename_all = "camelCase")]` -- TypeScript receives `{ relayHealthy, session, sessionError, share1InKeychain }` and `{ did, handle, email, emailConfirmed, didDoc }` 52 - - `start_oauth_flow` maps failures to typed `OAuthError` variants (DPOP_KEY_GEN_FAILED, DPOP_KEY_INVALID, DPOP_PROOF_FAILED, KEYCHAIN_ERROR, STATE_MISMATCH, CALLBACK_ABANDONED, PAR_FAILED, TOKEN_EXCHANGE_FAILED, TOKEN_REFRESH_FAILED, NOT_AUTHENTICATED) serialized as `{ code: "SCREAMING_SNAKE_CASE" }` for the frontend 52 + - `start_oauth_flow` maps failures to typed `OAuthError` variants (DPOP_KEY_GEN_FAILED, DPOP_KEY_INVALID, DPOP_PROOF_FAILED, KEYCHAIN_ERROR, STATE_MISMATCH, CALLBACK_ABANDONED, PAR_FAILED, TOKEN_EXCHANGE_FAILED, TOKEN_REFRESH_FAILED, INVALID_GRANT, NOT_AUTHENTICATED) serialized as `{ code: "SCREAMING_SNAKE_CASE" }` for the frontend 53 53 - `tauri.conf.json` registers `deep-link` plugin with mobile scheme `dev.malpercio.identitywallet`; deep-link URLs matching `dev.malpercio.identitywallet:/oauth/callback?code=...&state=...` are routed to `handle_deep_link` 54 54 - On app startup, if OAuth tokens exist in Keychain, the session is restored into `AppState.oauth_session` and an `auth_ready` Tauri event is emitted after a 300ms delay (allows SvelteKit to boot and register its listener) 55 55 - `OAuthClient` transparently refreshes access tokens with <60s remaining before each request; retries once on `use_dpop_nonce` 400 responses from the server
+22 -11
apps/identity-wallet/src-tauri/src/home.rs
··· 83 83 let oauth_client = match crate::oauth_client::OAuthClient::new(session_arc.clone()) { 84 84 Ok(c) => c, 85 85 Err(e) => { 86 + tracing::error!(error = %e, "OAuthClient construction failed"); 86 87 return Ok(HomeData { 87 88 relay_healthy: check_relay_health().await, 88 89 session: None, ··· 125 126 tracing::warn!(status = %resp.status(), "getSession returned non-success"); 126 127 (None, Some("NOT_AUTHENTICATED".to_string())) 127 128 } 128 - Err(e) => (None, Some(oauth_error_code(&e))), 129 + Err(e) => { 130 + tracing::error!(error = %e, "getSession request failed"); 131 + (None, Some(oauth_error_code(&e))) 132 + } 129 133 }; 130 134 131 135 Ok(HomeData { ··· 142 146 /// unconditionally navigates to the welcome screen. 143 147 #[tauri::command] 144 148 pub async fn log_out(state: tauri::State<'_, AppState>) -> Result<(), String> { 145 - let _ = crate::keychain::delete_item("oauth-access-token"); 146 - let _ = crate::keychain::delete_item("oauth-refresh-token"); 147 - let _ = crate::keychain::delete_item("did"); 149 + for key in &["oauth-access-token", "oauth-refresh-token", "did"] { 150 + if let Err(e) = crate::keychain::delete_item(key) { 151 + if !crate::keychain::is_not_found(&e) { 152 + tracing::warn!(error = %e, key = key, "Keychain delete failed during logout"); 153 + } 154 + } 155 + } 148 156 *state.oauth_session.lock().unwrap() = None; 149 157 Ok(()) 150 158 } ··· 166 174 serde_json::to_value(e) 167 175 .ok() 168 176 .and_then(|v| v["code"].as_str().map(String::from)) 169 - .unwrap_or_else(|| "UNKNOWN".to_string()) 177 + .unwrap_or_else(|| { 178 + tracing::warn!("OAuthError could not be serialized to a code string"); 179 + "UNKNOWN".to_string() 180 + }) 170 181 } 171 182 172 183 // ── Test helper: injectable base URLs ───────────────────────────────────── ··· 335 346 336 347 #[tokio::test] 337 348 async fn log_out_succeeds_when_keychain_items_absent() { 338 - // Items may not exist — log_out must not panic. AC4.7. 349 + // Items may not exist — log_out must not panic. 339 350 let state = AppState::new(); 340 351 simulate_log_out(&state); 341 352 } ··· 352 363 353 364 // OAuth items gone. 354 365 assert!(crate::keychain::get_item("oauth-access-token").is_err()); 355 - // Device and DPoP keys must NOT have been deleted (AC3.3). 366 + // Device and DPoP keys must NOT have been deleted. 356 367 assert!( 357 368 crate::keychain::get_item("oauth-dpop-key-priv").is_ok(), 358 369 "DPoP key must remain after logout" ··· 386 397 assert_eq!(data.session_error.as_deref(), Some("NOT_AUTHENTICATED")); 387 398 } 388 399 389 - // ── load_home_data: relay health (AC4.1, AC4.3, AC2.1, AC2.2) ───────── 400 + // ── load_home_data: relay health ────────────────────────────────────── 390 401 391 402 #[tokio::test] 392 403 async fn load_home_data_relay_healthy_true_when_health_returns_200() { ··· 452 463 !data.relay_healthy, 453 464 "relay_healthy must be false when _health returns 503" 454 465 ); 455 - // Session can still be populated (AC2.5: statuses are independent) 466 + // Session can still be populated when relay fails; statuses are independent. 456 467 assert!( 457 468 data.session.is_some(), 458 469 "session should still be populated when relay fails" 459 470 ); 460 471 } 461 472 462 - // ── load_home_data: session (AC4.2, AC4.4, AC2.3, AC2.4) ────────────── 473 + // ── load_home_data: session ──────────────────────────────────────────── 463 474 464 475 #[tokio::test] 465 476 async fn load_home_data_session_populated_when_get_session_succeeds() { ··· 522 533 data.session_error.is_some(), 523 534 "sessionError must be set when getSession fails" 524 535 ); 525 - // relay is still healthy (AC2.5: independent statuses) 536 + // relay is still healthy; statuses are independent 526 537 assert!(data.relay_healthy); 527 538 } 528 539 }
+5 -3
apps/identity-wallet/src/lib/components/home/DIDDocumentScreen.svelte
··· 9 9 10 10 let showRaw = $state(false); 11 11 let copiedKeyId = $state<string | null>(null); 12 + let failedKeyId = $state<string | null>(null); 12 13 13 14 // Extract typed arrays from the loosely-typed didDoc. 14 15 let verificationMethods = $derived( ··· 36 37 await navigator.clipboard.writeText(value); 37 38 copiedKeyId = keyId; 38 39 setTimeout(() => { copiedKeyId = null; }, 2000); 39 - } catch (e) { 40 - console.error('clipboard write failed:', e); 40 + } catch { 41 + failedKeyId = keyId; 42 + setTimeout(() => { failedKeyId = null; }, 2000); 41 43 } 42 44 } 43 45 </script> ··· 79 81 class="copy-btn" 80 82 onclick={() => copyKey(String(method.id), String(method.publicKeyMultibase))} 81 83 > 82 - {copiedKeyId === String(method.id) ? 'Copied!' : 'Copy'} 84 + {copiedKeyId === String(method.id) ? 'Copied!' : failedKeyId === String(method.id) ? 'Failed' : 'Copy'} 83 85 </button> 84 86 </div> 85 87 {/if}
+17 -6
apps/identity-wallet/src/lib/components/home/HomeScreen.svelte
··· 16 16 let homeData = $state<HomeData | null>(null); 17 17 let loading = $state(true); 18 18 let didCopied = $state(false); 19 + let didCopyFailed = $state(false); 19 20 20 21 async function loadData() { 21 22 loading = true; 22 - homeData = await loadHomeData(); 23 - loading = false; 23 + try { 24 + homeData = await loadHomeData(); 25 + } catch { 26 + homeData = null; 27 + } finally { 28 + loading = false; 29 + } 24 30 } 25 31 26 32 onMount(() => { ··· 45 51 await navigator.clipboard.writeText(did); 46 52 didCopied = true; 47 53 setTimeout(() => { didCopied = false; }, 2000); 48 - } catch (e) { 49 - console.error('clipboard write failed:', e); 54 + } catch { 55 + didCopyFailed = true; 56 + setTimeout(() => { didCopyFailed = false; }, 2000); 50 57 } 51 58 } 52 59 53 60 async function handleLogOut() { 54 - await logOut(); 61 + try { 62 + await logOut(); 63 + } catch { 64 + // logOut always succeeds on the Rust side; navigate away even if IPC fails 65 + } 55 66 onlogout(); 56 67 } 57 68 </script> ··· 76 87 <p class="handle">@{homeData.session.handle}</p> 77 88 <button class="did-btn" onclick={copyDid} title="Tap to copy full DID"> 78 89 <span class="did-text">{displayDid}</span> 79 - <span class="copy-hint">{didCopied ? 'Copied!' : 'Copy'}</span> 90 + <span class="copy-hint">{didCopied ? 'Copied!' : didCopyFailed ? 'Failed' : 'Copy'}</span> 80 91 </button> 81 92 <p class="email">{homeData.session.email}</p> 82 93 </div>
+3 -1
apps/identity-wallet/src/lib/ipc.ts
··· 217 217 * Always resolves — never rejects. Partial failures encoded in HomeData fields. 218 218 */ 219 219 export const loadHomeData = (): Promise<HomeData> => 220 - invoke<HomeData>('load_home_data'); 220 + invoke<HomeData>('load_home_data').catch( 221 + (): HomeData => ({ relayHealthy: false, session: null, sessionError: 'UNKNOWN', share1InKeychain: false }) 222 + ); 221 223 222 224 /** 223 225 * Clear OAuth access token, refresh token, and DID from Keychain and wipe
+1 -1
docs/design-plans/2026-03-27-MM-150.md
··· 11 11 1. A `HomeScreen` Svelte component replaces the `authenticated` stub in `+page.svelte`, showing the user's DID (truncated + copy button), handle, email, and a deterministic DID-derived avatar — all loaded from `getSession` on mount. 12 12 2. Relay and session status indicators are shown accurately: relay connectivity via `GET /xrpc/_health` (connected / error), session validity via `getSession` success/failure after transparent OAuth auto-refresh (active / error — two states only). 13 13 3. Three action flows are implemented and working: View DID Document (structured sheet with optional raw JSON toggle), Log out (clears OAuth access + refresh tokens and DID from Keychain, returns to welcome screen), Recovery info (read-only display of Share 1 in Keychain ✓, Share 2 on relay ✓, Share 3 user-managed). 14 - 4. New Tauri commands (`get_session`, `check_health`, `log_out`) and their `ipc.ts` typed wrappers are in place, following existing IPC patterns. 14 + 4. New Tauri commands (`load_home_data`, `log_out`) and their `ipc.ts` typed wrappers are in place, following existing IPC patterns. 15 15 5. The app launches directly to the home screen when already onboarded (existing `auth_ready` event mechanism — no new work required). 16 16 17 17 ## Acceptance Criteria