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 IPC commands and Keychain persistence

Add get_relay_url() and save_relay_url() IPC commands to expose relay URL
configuration to the frontend. Implement Keychain persistence with
store_relay_url() and load_relay_url() helpers, plus delete_relay_url_test_only()
for test cleanup.

Add RelayConfigError enum with three variants (InvalidUrl, Unreachable,
KeychainError) serializing to SCREAMING_SNAKE_CASE for the frontend.

Implement normalize_relay_url() helper that validates URLs are http/https with
non-empty host, and strips trailing slashes before saving.

Update run() setup block to restore relay URL from Keychain on startup if
previously configured, ensuring all IPC commands use the saved URL.

Add 6 unit tests covering normalize_relay_url variants and get_relay_url/
load_relay_url round-trip with proper test cleanup.

All tests pass.

authored by

Malpercio and committed by
Tangled
6014c852 ad8ac963

+168
+33
apps/identity-wallet/src-tauri/src/keychain.rs
··· 86 86 const DPOP_KEY_PRIV_ACCOUNT: &str = "oauth-dpop-key-priv"; 87 87 const OAUTH_ACCESS_TOKEN_ACCOUNT: &str = "oauth-access-token"; 88 88 const OAUTH_REFRESH_TOKEN_ACCOUNT: &str = "oauth-refresh-token"; 89 + const RELAY_URL_ACCOUNT: &str = "relay-base-url"; 89 90 90 91 /// Store the DPoP private key scalar (32 bytes) in the Keychain. 91 92 pub fn store_dpop_key(private_bytes: &[u8]) -> Result<(), KeychainError> { ··· 155 156 } 156 157 }; 157 158 Some((access, refresh)) 159 + } 160 + 161 + /// Persist the user-configured relay base URL to the Keychain. 162 + /// 163 + /// Overwrites any previously stored URL. 164 + pub fn store_relay_url(url: &str) -> Result<(), KeychainError> { 165 + store_item(RELAY_URL_ACCOUNT, url.as_bytes()) 166 + } 167 + 168 + /// Retrieve the user-configured relay base URL from the Keychain. 169 + /// 170 + /// Returns `None` if no URL has been saved yet (first run or after logout). 171 + pub fn load_relay_url() -> Option<String> { 172 + match get_item(RELAY_URL_ACCOUNT) { 173 + Ok(bytes) => String::from_utf8(bytes) 174 + .map_err(|e| { 175 + tracing::warn!(error = %e, "relay URL in Keychain is not valid UTF-8; treating as absent"); 176 + }) 177 + .ok(), 178 + Err(e) if is_not_found(&e) => None, 179 + Err(e) => { 180 + tracing::error!(error = ?e, "Keychain error loading relay URL"); 181 + None 182 + } 183 + } 184 + } 185 + 186 + /// Remove the relay URL from the Keychain. Test-only; used to reset state 187 + /// between tests that share the Keychain mock store. 188 + #[cfg(test)] 189 + pub fn delete_relay_url_test_only() { 190 + let _ = delete_item(RELAY_URL_ACCOUNT); 158 191 } 159 192 160 193 /// In-memory Keychain substitute used exclusively in test builds.
+135
apps/identity-wallet/src-tauri/src/lib.rs
··· 217 217 Unknown { message: String }, 218 218 } 219 219 220 + /// Error returned by relay URL configuration commands. 221 + /// 222 + /// Serializes as `{ "code": "INVALID_URL" | "UNREACHABLE" | "KEYCHAIN_ERROR" }` for the frontend. 223 + #[derive(Debug, Serialize, thiserror::Error)] 224 + #[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")] 225 + pub enum RelayConfigError { 226 + #[error("invalid relay URL: must be http or https with a non-empty host")] 227 + InvalidUrl, 228 + #[error("relay is unreachable or did not return a success response")] 229 + Unreachable, 230 + #[error("failed to save relay URL to device storage")] 231 + KeychainError, 232 + } 233 + 220 234 /// Response shape from `GET /xrpc/com.atproto.identity.resolveHandle`. 221 235 #[derive(Deserialize)] 222 236 struct ResolveHandleResponse { ··· 235 249 message: format!("409: {other}"), 236 250 }, 237 251 } 252 + } 253 + 254 + /// Validate a relay URL: must parse as http or https with a non-empty host. 255 + /// Strips any trailing slash and returns the normalized URL string. 256 + fn normalize_relay_url(url: &str) -> Result<String, RelayConfigError> { 257 + let parsed = url::Url::parse(url).map_err(|_| RelayConfigError::InvalidUrl)?; 258 + match parsed.scheme() { 259 + "http" | "https" => {} 260 + _ => return Err(RelayConfigError::InvalidUrl), 261 + } 262 + if parsed.host().is_none() { 263 + return Err(RelayConfigError::InvalidUrl); 264 + } 265 + Ok(url.trim_end_matches('/').to_string()) 238 266 } 239 267 240 268 // ── IPC command ───────────────────────────────────────────────────────────── ··· 579 607 } 580 608 } 581 609 610 + /// Return the saved relay base URL, or `None` if not yet configured. 611 + /// 612 + /// The frontend calls this on mount to decide whether to show the relay 613 + /// configuration screen. 614 + #[tauri::command] 615 + fn get_relay_url() -> Option<String> { 616 + keychain::load_relay_url() 617 + } 618 + 619 + /// Validate `url`, confirm the relay is reachable, save to Keychain, and 620 + /// initialize the runtime relay client. 621 + /// 622 + /// After this call succeeds, all subsequent IPC commands that use the relay 623 + /// will use the saved URL for the remainder of the app session and on all 624 + /// future launches. 625 + #[tauri::command] 626 + async fn save_relay_url( 627 + url: String, 628 + state: tauri::State<'_, oauth::AppState>, 629 + ) -> Result<(), RelayConfigError> { 630 + let normalized = normalize_relay_url(&url)?; 631 + let resp = http::RelayClient::new_with_url(normalized.clone()) 632 + .get("/xrpc/_health") 633 + .await 634 + .map_err(|_| RelayConfigError::Unreachable)?; 635 + if !resp.status().is_success() { 636 + tracing::warn!( 637 + status = %resp.status(), 638 + url = %normalized, 639 + "relay health check returned non-success status" 640 + ); 641 + return Err(RelayConfigError::Unreachable); 642 + } 643 + keychain::store_relay_url(&normalized).map_err(|e| { 644 + tracing::error!(error = %e, "failed to save relay URL to Keychain"); 645 + RelayConfigError::KeychainError 646 + })?; 647 + state.set_relay_client(normalized); 648 + Ok(()) 649 + } 650 + 582 651 /// Check whether the relay can resolve `handle` to `expected_did` via the ATProto 583 652 /// `resolveHandle` endpoint. 584 653 /// ··· 627 696 .plugin(tauri_plugin_deep_link::init()) 628 697 .plugin(tauri_plugin_opener::init()) 629 698 .setup(|app| { 699 + // Restore relay URL from Keychain if previously configured. 700 + if let Some(url) = keychain::load_relay_url() { 701 + app.state::<oauth::AppState>().set_relay_client(url); 702 + } 703 + 630 704 let app_handle = app.app_handle().clone(); 631 705 app.deep_link().on_open_url(move |event| { 632 706 let state = app_handle.state::<oauth::AppState>(); ··· 663 737 perform_did_ceremony, 664 738 register_handle, 665 739 check_handle_resolution, 740 + get_relay_url, 741 + save_relay_url, 666 742 home::load_home_data, 667 743 home::log_out, 668 744 oauth::start_oauth_flow, ··· 983 1059 fn did_ceremony_error_share_storage_failed_serializes_correctly() { 984 1060 let json = serde_json::to_value(&DIDCeremonyError::ShareStorageFailed).unwrap(); 985 1061 assert_eq!(json["code"], "SHARE_STORAGE_FAILED"); 1062 + } 1063 + 1064 + // -- normalize_relay_url -- 1065 + 1066 + #[test] 1067 + fn normalize_relay_url_strips_trailing_slash() { 1068 + assert_eq!( 1069 + normalize_relay_url("https://relay.example.com/").unwrap(), 1070 + "https://relay.example.com" 1071 + ); 1072 + } 1073 + 1074 + #[test] 1075 + fn normalize_relay_url_accepts_http_and_https() { 1076 + assert!(normalize_relay_url("https://relay.example.com").is_ok()); 1077 + assert!(normalize_relay_url("http://localhost:8080").is_ok()); 1078 + } 1079 + 1080 + #[test] 1081 + fn normalize_relay_url_rejects_non_http_schemes() { 1082 + assert!(matches!( 1083 + normalize_relay_url("ftp://relay.example.com").unwrap_err(), 1084 + RelayConfigError::InvalidUrl 1085 + )); 1086 + assert!(matches!( 1087 + normalize_relay_url("ws://relay.example.com").unwrap_err(), 1088 + RelayConfigError::InvalidUrl 1089 + )); 1090 + } 1091 + 1092 + #[test] 1093 + fn normalize_relay_url_rejects_malformed_input() { 1094 + assert!(matches!( 1095 + normalize_relay_url("not-a-url").unwrap_err(), 1096 + RelayConfigError::InvalidUrl 1097 + )); 1098 + assert!(matches!( 1099 + normalize_relay_url("").unwrap_err(), 1100 + RelayConfigError::InvalidUrl 1101 + )); 1102 + } 1103 + 1104 + // -- get_relay_url / load_relay_url round-trip -- 1105 + 1106 + #[test] 1107 + fn get_relay_url_returns_none_before_save() { 1108 + // The Keychain test mock starts empty in a fresh process; tests that 1109 + // write to the store must clean up via delete_relay_url_test_only(). 1110 + assert!(get_relay_url().is_none()); 1111 + } 1112 + 1113 + #[test] 1114 + fn relay_url_round_trips_through_keychain() { 1115 + let url = "https://relay.example.com"; 1116 + keychain::store_relay_url(url).unwrap(); 1117 + let loaded = keychain::load_relay_url().unwrap(); 1118 + assert_eq!(loaded, url); 1119 + // Clean up so this test doesn't affect others sharing the mock store. 1120 + keychain::delete_relay_url_test_only(); 986 1121 } 987 1122 }