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.

feat: add relay URL configuration screen to onboarding

- Add RelayConfigError type and IPC wrappers (getRelayUrl, saveRelayUrl) to ipc.ts
- Create RelayConfigScreen.svelte component with URL validation and health check UI
- Update +page.svelte to include relay_config as initial step with onMount check
- Add relay_config to OnboardingStep type and update state machine to 16 steps
- Update CLAUDE.md to reflect relay URL config, runtime-configurable relay client,
new Keychain account, and updated component/IPC documentation

authored by

Malpercio and committed by
Tangled
2e05f400 6014c852

+232 -17
+16 -12
apps/identity-wallet/CLAUDE.md
··· 1 1 # Identity Wallet Mobile App 2 2 3 - Last verified: 2026-03-27 3 + Last verified: 2026-03-28 4 4 5 5 ## Purpose 6 6 ··· 11 11 ### Frontend (SvelteKit 2 + Svelte 5) 12 12 13 13 **Exposes:** 14 - - `src/lib/ipc.ts` — typed wrappers for all Tauri IPC commands; import these instead of calling `invoke()` directly. Exports: `createAccount()`, `getOrCreateDeviceKey()`, `signWithDeviceKey()`, `performDIDCeremony()`, `startOAuthFlow()`, `loadHomeData()`, `logOut()`, and their associated types (`DevicePublicKey`, `DeviceKeyError`, `CreateAccountResult`, `CreateAccountError`, `DIDCeremonyResult`, `DIDCeremonyError`, `OAuthError`, `SessionInfo`, `HomeData`) 15 - - `src/lib/components/onboarding/` — ten onboarding screen components (WelcomeScreen, ClaimCodeScreen, EmailScreen, HandleScreen, PasswordScreen, LoadingScreen, DIDCeremonyScreen, DIDSuccessScreen, ShamirBackupScreen, AuthenticatingScreen) 14 + - `src/lib/ipc.ts` — typed wrappers for all Tauri IPC commands; import these instead of calling `invoke()` directly. Exports: `createAccount()`, `getOrCreateDeviceKey()`, `signWithDeviceKey()`, `performDIDCeremony()`, `startOAuthFlow()`, `loadHomeData()`, `logOut()`, `getRelayUrl()`, `saveRelayUrl()`, and their associated types (`DevicePublicKey`, `DeviceKeyError`, `CreateAccountResult`, `CreateAccountError`, `DIDCeremonyResult`, `DIDCeremonyError`, `OAuthError`, `SessionInfo`, `HomeData`, `RelayConfigError`) 15 + - `src/lib/components/onboarding/` — eleven onboarding screen components (RelayConfigScreen, WelcomeScreen, ClaimCodeScreen, EmailScreen, HandleScreen, PasswordScreen, LoadingScreen, DIDCeremonyScreen, DIDSuccessScreen, ShamirBackupScreen, AuthenticatingScreen) 16 16 - `src/lib/components/home/` — three home screen components (HomeScreen, DIDDocumentScreen, RecoveryInfoScreen) plus DIDAvatar utility component (deterministic DID-derived hue circle) 17 - - `src/routes/+page.svelte` — root page: fifteen-step state machine (welcome -> claim_code -> email -> handle -> password -> loading -> did_ceremony -> did_success -> shamir_backup -> complete -> authenticating -> home -> did_document / recovery_info / auth_failed) 17 + - `src/routes/+page.svelte` — root page: sixteen-step state machine (relay_config -> welcome -> claim_code -> email -> handle -> password -> loading -> did_ceremony -> did_success -> shamir_backup -> handle_registration -> complete -> authenticating -> home -> did_document / recovery_info / auth_failed) 18 18 19 19 **Guarantees:** 20 20 - SSR is disabled globally (`ssr = false` in `src/routes/+layout.ts`); the frontend is a fully static SPA loaded from disk by WKWebView ··· 37 37 - `src/oauth.rs` — OAuth PKCE client module: `AppState` (pending_auth + oauth_session mutexes), `OAuthSession` (access/refresh/expiry/nonce), `DPoPKeypair` (P-256, persisted in Keychain), `OAuthError` enum, PKCE utilities (verifier + S256 challenge), `start_oauth_flow` (Tauri IPC command: DPoP keygen, PKCE, PAR, Safari redirect, deep-link callback, token exchange), `handle_deep_link` (routes deep-link URLs to pending flow) 38 38 - `src/oauth_client.rs` — `OAuthClient`: authenticated HTTP client wrapping every request with `Authorization: DPoP {access_token}` + `DPoP` proof headers; transparent lazy refresh when token has <60s remaining; automatic retry on `use_dpop_nonce` 400 responses; methods: `get(path)`, `post(path, body)` 39 39 - `src/device_key.rs` — P-256 device key management with `#[cfg]`-based dispatch: macOS/simulator uses software keys via `crypto` crate + Keychain storage; real iOS device uses Secure Enclave via `security-framework`. Public API: `get_or_create() -> Result<DevicePublicKey, DeviceKeyError>` (idempotent), `sign(data) -> Result<Vec<u8>, DeviceKeyError>` 40 - - `src/keychain.rs` — iOS Keychain abstraction (`store_item`, `get_item`, `delete_item`) under service `"ezpds-identity-wallet"`; OAuth helpers: `store_dpop_key`/`load_dpop_key` (P-256 DPoP private key scalar), `store_oauth_tokens`/`load_oauth_tokens` (access + refresh token pair) 41 - - `src/http.rs` — `RelayClient` with compile-time base URL (localhost:8080 debug, relay.ezpds.com release); methods: `post()`, `get()`, `post_with_bearer()`, `par()` (POST /oauth/par with DPoP proof), `token_exchange()` (POST /oauth/token with PKCE verifier); static `base_url()` accessor; response types: `ParResponse`, `TokenResponse`, `TokenErrorResponse` 40 + - `src/keychain.rs` — iOS Keychain abstraction (`store_item`, `get_item`, `delete_item`) under service `"ezpds-identity-wallet"`; Relay URL account: `store_relay_base_url`/`load_relay_base_url` (relay base URL); OAuth helpers: `store_dpop_key`/`load_dpop_key` (P-256 DPoP private key scalar), `store_oauth_tokens`/`load_oauth_tokens` (access + refresh token pair) 41 + - `src/http.rs` — `RelayClient` with runtime-configurable base URL (initialized via `AppState::set_relay_client(url)` on first launch; localhost:8080 debug fallback); methods: `post()`, `get()`, `post_with_bearer()`, `par()` (POST /oauth/par with DPoP proof), `token_exchange()` (POST /oauth/token with PKCE verifier); response types: `ParResponse`, `TokenResponse`, `TokenErrorResponse` 42 + - `src/lib.rs::get_relay_url() -> Result<Option<String>, RelayConfigError>` — Tauri IPC command: loads relay base URL from Keychain, returns Some(url) if configured or None for first-launch 43 + - `src/lib.rs::save_relay_url(url: String) -> Result<(), RelayConfigError>` — Tauri IPC command: validates URL format, pings `/xrpc/_health` on the relay, saves to Keychain, initializes `AppState.relay_client` (runtime configuration) 42 44 43 45 **Guarantees:** 44 46 - `crate-type = ["staticlib", "cdylib", "rlib"]` supports iOS (staticlib), Android (cdylib), and normal cargo builds (rlib) ··· 215 217 - `CreateAccountError` variant names serialize as SCREAMING_SNAKE_CASE to the frontend -- the TypeScript `CreateAccountError.code` union must match exactly 216 218 - `DeviceKeyError` variant names serialize as SCREAMING_SNAKE_CASE to the frontend -- the TypeScript `DeviceKeyError.code` union must match exactly 217 219 - `DIDCeremonyError` variant names serialize as SCREAMING_SNAKE_CASE to the frontend -- the TypeScript `DIDCeremonyError.code` union must match exactly 220 + - `RelayConfigError` variant names serialize as SCREAMING_SNAKE_CASE to the frontend -- the TypeScript `RelayConfigError.code` union must match exactly (INVALID_URL, UNREACHABLE, KEYCHAIN_ERROR) 221 + - Keychain account `"relay-base-url"` stores the relay's base URL (e.g. `https://relay.ezpds.com`); persisted by `save_relay_url` on first launch; `get_relay_url` returns null if not yet set 218 222 - Keychain account `"device-rotation-key-priv"` stores the software P-256 private key (simulator/macOS path only); changing it orphans existing keys 219 223 - Keychain accounts `"device-rotation-key-pub"` and `"device-rotation-key-app-label"` store SE metadata (real iOS device path only); changing them orphans the SE key lookup 220 224 - Keychain account `"session-token"` stores the pending (pre-DID) or full (post-DID) session token; `perform_did_ceremony` reads the pending token and overwrites it with the upgraded token on success ··· 234 238 ## Key Files 235 239 236 240 - `src-tauri/tauri.conf.json` -- Tauri config: bundle ID, devUrl, frontendDist, window settings 237 - - `src-tauri/src/lib.rs` -- Tauri IPC commands (`create_account`, `get_or_create_device_key`, `sign_with_device_key`, `perform_did_ceremony`, `start_oauth_flow`, `home::load_home_data`, `home::log_out`), `run()` (mobile entry point), deep-link plugin setup, startup token restore 241 + - `src-tauri/src/lib.rs` -- Tauri IPC commands (`get_relay_url`, `save_relay_url`, `create_account`, `get_or_create_device_key`, `sign_with_device_key`, `perform_did_ceremony`, `start_oauth_flow`, `home::load_home_data`, `home::log_out`), `run()` (mobile entry point), deep-link plugin setup, startup token restore 238 242 - `src-tauri/src/home.rs` -- Home screen Tauri commands: `load_home_data` (concurrent relay health + getSession), `log_out` (Keychain wipe + session clear); output types: HomeData, SessionInfo 239 243 - `src-tauri/src/device_key.rs` -- P-256 device key module: `#[cfg]`-dispatched `get_or_create()` and `sign()` (simulator software path vs. Secure Enclave) 240 244 - `src-tauri/src/main.rs` -- Desktop entry point (calls `lib::run()`) 241 245 - `src-tauri/src/oauth.rs` -- OAuth PKCE module: AppState, DPoPKeypair, OAuthSession, PKCE utilities, start_oauth_flow command, handle_deep_link 242 246 - `src-tauri/src/oauth_client.rs` -- OAuthClient: authenticated HTTP client with DPoP proofs and lazy token refresh 243 - - `src-tauri/src/keychain.rs` -- iOS Keychain abstraction (store_item, get_item, delete_item); OAuth helpers (store_dpop_key, load_dpop_key, store_oauth_tokens, load_oauth_tokens) 244 - - `src-tauri/src/http.rs` -- RelayClient with compile-time base URL; OAuth methods (par, token_exchange) 247 + - `src-tauri/src/keychain.rs` -- iOS Keychain abstraction (store_item, get_item, delete_item); Relay URL helpers (store_relay_base_url, load_relay_base_url); OAuth helpers (store_dpop_key, load_dpop_key, store_oauth_tokens, load_oauth_tokens) 248 + - `src-tauri/src/http.rs` -- RelayClient with runtime-configurable base URL; OAuth methods (par, token_exchange) 245 249 - `src-tauri/.cargo/config.toml` -- Cargo toolchain overrides for iOS cross-compilation (CC, AR, linker per target) 246 - - `src/lib/ipc.ts` -- Typed TypeScript wrappers for all Tauri IPC commands (createAccount, getOrCreateDeviceKey, signWithDeviceKey, performDIDCeremony, startOAuthFlow, loadHomeData, logOut) 247 - - `src/lib/components/onboarding/` -- Ten onboarding screen components (WelcomeScreen, ClaimCodeScreen, EmailScreen, HandleScreen, PasswordScreen, LoadingScreen, DIDCeremonyScreen, DIDSuccessScreen, ShamirBackupScreen, AuthenticatingScreen) 250 + - `src/lib/ipc.ts` -- Typed TypeScript wrappers for all Tauri IPC commands (getRelayUrl, saveRelayUrl, createAccount, getOrCreateDeviceKey, signWithDeviceKey, performDIDCeremony, startOAuthFlow, loadHomeData, logOut) 251 + - `src/lib/components/onboarding/` -- Eleven onboarding screen components (RelayConfigScreen, WelcomeScreen, ClaimCodeScreen, EmailScreen, HandleScreen, PasswordScreen, LoadingScreen, DIDCeremonyScreen, DIDSuccessScreen, ShamirBackupScreen, AuthenticatingScreen) 248 252 - `src/lib/components/home/` -- Three home screen components (HomeScreen, DIDDocumentScreen, RecoveryInfoScreen) plus DIDAvatar utility component 249 - - `src/routes/+page.svelte` -- State machine (welcome -> claim_code -> email -> handle -> password -> loading -> did_ceremony -> did_success -> shamir_backup -> complete -> authenticating -> home -> did_document / recovery_info / auth_failed) 253 + - `src/routes/+page.svelte` -- State machine (relay_config -> welcome -> claim_code -> email -> handle -> password -> loading -> did_ceremony -> did_success -> shamir_backup -> handle_registration -> complete -> authenticating -> home -> did_document / recovery_info / auth_failed) 250 254 - `src/routes/+layout.ts` -- `ssr = false; prerender = false` (global SPA config) 251 255 - `svelte.config.js` -- adapter-static with `pages: 'dist'` (SPA mode, matches tauri.conf.json) 252 256 - `vite.config.ts` -- Tauri-compatible Vite server (clearScreen, HMR via TAURI_DEV_HOST, envPrefix)
+177
apps/identity-wallet/src/lib/components/onboarding/RelayConfigScreen.svelte
··· 1 + <script lang="ts"> 2 + import { saveRelayUrl, type RelayConfigError } from '$lib/ipc'; 3 + 4 + const DEFAULT_RELAY_URL = 'https://relay.ezpds.com'; 5 + 6 + let { onnext }: { onnext: () => void } = $props(); 7 + 8 + let url = $state(DEFAULT_RELAY_URL); 9 + let loading = $state(false); 10 + let error = $state<string | undefined>(undefined); 11 + 12 + let isValidFormat = $derived( 13 + url.trim().length > 0 && 14 + (url.startsWith('http://') || url.startsWith('https://')) 15 + ); 16 + 17 + async function handleConnect() { 18 + error = undefined; 19 + loading = true; 20 + try { 21 + await saveRelayUrl(url.trim()); 22 + onnext(); 23 + } catch (e) { 24 + const relayError = e as RelayConfigError; 25 + if (relayError.code === 'INVALID_URL') { 26 + error = 'Invalid URL — must start with http:// or https://'; 27 + } else if (relayError.code === 'KEYCHAIN_ERROR') { 28 + error = 'Could not save the relay URL. Please try again.'; 29 + } else { 30 + error = 'Could not reach the relay. Check the URL and try again.'; 31 + } 32 + } finally { 33 + loading = false; 34 + } 35 + } 36 + </script> 37 + 38 + <div class="screen"> 39 + <div class="content"> 40 + <h2>Connect to Relay</h2> 41 + <p class="hint"> 42 + Your wallet connects to a relay to create your identity. Use the default 43 + or enter the address of your own relay. 44 + </p> 45 + 46 + <input 47 + type="url" 48 + class:error={!!error} 49 + disabled={loading} 50 + bind:value={url} 51 + placeholder="https://relay.ezpds.com" 52 + autocomplete="off" 53 + autocorrect="off" 54 + autocapitalize="off" 55 + spellcheck={false} 56 + /> 57 + 58 + {#if error} 59 + <p class="error-text">{error}</p> 60 + {/if} 61 + </div> 62 + 63 + <div class="actions"> 64 + {#if loading} 65 + <div class="spinner" role="status" aria-label="Connecting…"></div> 66 + {:else} 67 + <button disabled={!isValidFormat} onclick={handleConnect}>Connect</button> 68 + {/if} 69 + </div> 70 + </div> 71 + 72 + <style> 73 + .screen { 74 + display: flex; 75 + flex-direction: column; 76 + height: 100%; 77 + padding: 2rem; 78 + gap: 1.5rem; 79 + } 80 + 81 + .content { 82 + display: flex; 83 + flex-direction: column; 84 + align-items: center; 85 + flex: 1; 86 + justify-content: center; 87 + gap: 1rem; 88 + } 89 + 90 + h2 { 91 + font-size: 1.5rem; 92 + font-weight: 700; 93 + color: #111827; 94 + margin: 0; 95 + text-align: center; 96 + } 97 + 98 + .hint { 99 + font-size: 0.9rem; 100 + color: #6b7280; 101 + text-align: center; 102 + max-width: 280px; 103 + line-height: 1.4; 104 + margin: 0; 105 + } 106 + 107 + input { 108 + width: 100%; 109 + max-width: 320px; 110 + padding: 1rem; 111 + font-size: 1rem; 112 + border: 2px solid #d1d5db; 113 + border-radius: 12px; 114 + outline: none; 115 + font-family: monospace; 116 + color: #111827; 117 + } 118 + 119 + input:focus { 120 + border-color: #007aff; 121 + } 122 + 123 + input.error { 124 + border-color: #ef4444; 125 + } 126 + 127 + input:disabled { 128 + opacity: 0.6; 129 + } 130 + 131 + .error-text { 132 + font-size: 0.875rem; 133 + color: #ef4444; 134 + margin: 0; 135 + text-align: center; 136 + max-width: 320px; 137 + } 138 + 139 + .actions { 140 + display: flex; 141 + justify-content: center; 142 + padding-bottom: env(safe-area-inset-bottom, 0); 143 + } 144 + 145 + button { 146 + width: 100%; 147 + max-width: 320px; 148 + padding: 1rem; 149 + font-size: 1rem; 150 + font-weight: 600; 151 + background: #007aff; 152 + color: white; 153 + border: none; 154 + border-radius: 12px; 155 + cursor: pointer; 156 + } 157 + 158 + button:disabled { 159 + background: #9ca3af; 160 + cursor: not-allowed; 161 + } 162 + 163 + .spinner { 164 + width: 48px; 165 + height: 48px; 166 + border: 4px solid #e5e7eb; 167 + border-top-color: #007aff; 168 + border-radius: 50%; 169 + animation: spin 0.8s linear infinite; 170 + } 171 + 172 + @keyframes spin { 173 + to { 174 + transform: rotate(360deg); 175 + } 176 + } 177 + </style>
+23
apps/identity-wallet/src/lib/ipc.ts
··· 278 278 * Always resolves. Frontend should unconditionally navigate to the welcome screen. 279 279 */ 280 280 export const logOut = (): Promise<void> => invoke('log_out').then(() => undefined); 281 + 282 + // ── Relay URL Configuration ────────────────────────────────────────────── 283 + 284 + /** 285 + * Error from relay URL configuration commands. 286 + * Serialized as `{ code: "INVALID_URL" }` etc. by the Rust backend. 287 + */ 288 + export type RelayConfigError = { code: 'INVALID_URL' | 'UNREACHABLE' | 'KEYCHAIN_ERROR' }; 289 + 290 + /** 291 + * Returns the saved relay base URL, or null if not yet configured. 292 + * Call this on app mount to decide whether to show the relay config screen. 293 + */ 294 + export const getRelayUrl = (): Promise<string | null> => 295 + invoke('get_relay_url'); 296 + 297 + /** 298 + * Validates url, pings /xrpc/_health, saves to Keychain, and initializes the 299 + * runtime relay client. After this resolves, all relay IPC commands use url. 300 + * Throws RelayConfigError on failure. 301 + */ 302 + export const saveRelayUrl = (url: string): Promise<void> => 303 + invoke('save_relay_url', { url });
+16 -5
apps/identity-wallet/src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { listen } from '@tauri-apps/api/event'; 3 3 import { onMount } from 'svelte'; 4 + import RelayConfigScreen from '$lib/components/onboarding/RelayConfigScreen.svelte'; 4 5 import WelcomeScreen from '$lib/components/onboarding/WelcomeScreen.svelte'; 5 6 import ClaimCodeScreen from '$lib/components/onboarding/ClaimCodeScreen.svelte'; 6 7 import EmailScreen from '$lib/components/onboarding/EmailScreen.svelte'; ··· 15 16 import HomeScreen from '$lib/components/home/HomeScreen.svelte'; 16 17 import DIDDocumentScreen from '$lib/components/home/DIDDocumentScreen.svelte'; 17 18 import RecoveryInfoScreen from '$lib/components/home/RecoveryInfoScreen.svelte'; 18 - import { createAccount, type CreateAccountError, type OAuthError, type HomeData } from '$lib/ipc'; 19 + import { createAccount, getRelayUrl, type CreateAccountError, type OAuthError, type HomeData } from '$lib/ipc'; 19 20 20 21 // ── Onboarding step type ───────────────────────────────────────────────── 21 22 // ··· 28 29 // instead of navigating through an extra modal. No 'error' step is needed. 29 30 30 31 type OnboardingStep = 32 + | 'relay_config' 31 33 | 'welcome' 32 34 | 'claim_code' 33 35 | 'email' ··· 47 49 48 50 // ── State ──────────────────────────────────────────────────────────────── 49 51 50 - let step = $state<OnboardingStep>('welcome'); 52 + let step = $state<OnboardingStep>('relay_config'); 51 53 let form = $state({ claimCode: '', email: '', handle: '', password: '', did: '', share3: '', registeredHandle: '' }); 52 54 53 55 /** ··· 69 71 step = next; 70 72 } 71 73 72 - // ── OAuth event listener ────────────────────────────────────────────────── 74 + // ── Relay configuration and OAuth event listener ────────────────────── 75 + 76 + onMount(async () => { 77 + // If the user has already configured a relay URL, skip the config screen. 78 + const savedUrl = await getRelayUrl(); 79 + if (savedUrl) { 80 + step = 'welcome'; 81 + } 73 82 74 - onMount(() => { 83 + // Existing: listen for auth_ready deep-link callback from the OAuth flow. 75 84 listen('auth_ready', () => { 76 85 goTo('home'); 77 86 }); ··· 150 159 </script> 151 160 152 161 <div class="app"> 153 - {#if step === 'welcome'} 162 + {#if step === 'relay_config'} 163 + <RelayConfigScreen onnext={() => goTo('welcome')} /> 164 + {:else if step === 'welcome'} 154 165 <WelcomeScreen onstart={() => goTo('claim_code')} /> 155 166 {:else if step === 'claim_code'} 156 167 <ClaimCodeScreen