BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

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

feat: add explorer commands for input resolution, server description, and record management

+686 -22
+1 -18
src-tauri/src/auth.rs
··· 11 11 use jacquard::oauth::loopback::{CallbackHandle, LoopbackConfig, LoopbackPort}; 12 12 use jacquard::oauth::session::{AuthRequestData, ClientData, ClientSessionData}; 13 13 use jacquard::oauth::types::AuthorizeOptions; 14 - use jacquard::types::{aturi::AtUri, did::Did}; 14 + use jacquard::types::did::Did; 15 15 use jacquard::xrpc::XrpcClient; 16 16 use jacquard::IntoStatic; 17 17 use rusqlite::{params, OptionalExtension}; ··· 23 23 use tauri::{AppHandle, Emitter}; 24 24 25 25 pub const ACCOUNT_SWITCHED_EVENT: &str = "auth:account-switched"; 26 - pub const AT_URI_OPEN_EVENT: &str = "navigation:open-at-uri"; 27 26 const CLIENT_NAME: &str = "Lazurite"; 28 27 const LOGIN_TYPEAHEAD_LIMIT: usize = 6; 29 28 const LOGIN_TYPEAHEAD_CLIENT: &str = "lazurite-desktop"; ··· 46 45 pub pds_url: String, 47 46 pub active: bool, 48 47 pub avatar: Option<String>, 49 - } 50 - 51 - #[derive(Clone, Debug, Serialize)] 52 - #[serde(rename_all = "camelCase")] 53 - pub struct AtUriNavigation { 54 - pub uri: String, 55 48 } 56 49 57 50 #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] ··· 433 426 OAuthSession::new(oauth_client.registry.clone(), oauth_client.client.clone(), session_data) 434 427 } 435 428 436 - pub fn normalize_at_uri(raw: &str) -> Result<String, AppError> { 437 - Ok(AtUri::new(raw)?.to_string()) 438 - } 439 - 440 429 pub fn emit_account_switch(app: &AppHandle, active_session: Option<ActiveSession>) -> Result<(), AppError> { 441 430 app.emit(ACCOUNT_SWITCHED_EVENT, active_session)?; 442 - Ok(()) 443 - } 444 - 445 - pub fn emit_at_uri_navigation(app: &AppHandle, raw: &str) -> Result<(), AppError> { 446 - let uri = normalize_at_uri(raw)?; 447 - app.emit(AT_URI_OPEN_EVENT, AtUriNavigation { uri })?; 448 431 Ok(()) 449 432 } 450 433
+3
src-tauri/src/commands.rs src-tauri/src/commands/mod.rs
··· 1 1 #![allow(clippy::needless_pass_by_value)] 2 + 3 + pub mod explorer; 4 + 2 5 use super::auth::{self, LoginSuggestion}; 3 6 use super::error::AppError; 4 7 use super::feed::{self, CreateRecordResult, EmbedInput, FeedViewPrefItem, ReplyRefInput, UserPreferences};
+40
src-tauri/src/commands/explorer.rs
··· 1 + use crate::error::AppError; 2 + use crate::explorer; 3 + use tauri::AppHandle; 4 + 5 + #[tauri::command] 6 + pub async fn resolve_input(input: String) -> Result<explorer::ResolvedExplorerInput, AppError> { 7 + explorer::resolve_input(input).await 8 + } 9 + 10 + #[tauri::command] 11 + pub async fn describe_server(pds_url: String) -> Result<explorer::ExplorerServerView, AppError> { 12 + explorer::describe_server(pds_url).await 13 + } 14 + 15 + #[tauri::command] 16 + pub async fn describe_repo(did: String) -> Result<serde_json::Value, AppError> { 17 + explorer::describe_repo(did).await 18 + } 19 + 20 + #[tauri::command] 21 + pub async fn list_records( 22 + did: String, collection: String, cursor: Option<String>, 23 + ) -> Result<serde_json::Value, AppError> { 24 + explorer::list_records(did, collection, cursor).await 25 + } 26 + 27 + #[tauri::command] 28 + pub async fn get_record(did: String, collection: String, rkey: String) -> Result<serde_json::Value, AppError> { 29 + explorer::get_record(did, collection, rkey).await 30 + } 31 + 32 + #[tauri::command] 33 + pub async fn export_repo_car(did: String, app: AppHandle) -> Result<explorer::RepoCarExport, AppError> { 34 + explorer::export_repo_car(did, &app).await 35 + } 36 + 37 + #[tauri::command] 38 + pub async fn query_labels(uri: String) -> Result<serde_json::Value, AppError> { 39 + explorer::query_labels(uri).await 40 + }
+623
src-tauri/src/explorer.rs
··· 1 + use crate::error::{AppError, Result}; 2 + use jacquard::api::com_atproto::identity::resolve_handle::ResolveHandle; 3 + use jacquard::api::com_atproto::label::query_labels::QueryLabels; 4 + use jacquard::api::com_atproto::repo::describe_repo::DescribeRepo; 5 + use jacquard::api::com_atproto::repo::get_record::GetRecord; 6 + use jacquard::api::com_atproto::repo::list_records::ListRecords; 7 + use jacquard::api::com_atproto::server::describe_server::DescribeServer; 8 + use jacquard::api::com_atproto::sync::get_repo::GetRepo; 9 + use jacquard::api::com_atproto::sync::list_repos::ListRepos; 10 + use jacquard::client::{Agent, UnauthenticatedSession}; 11 + use jacquard::deps::fluent_uri::Uri; 12 + use jacquard::identity::JacquardResolver; 13 + use jacquard::types::aturi::AtUri; 14 + use jacquard::types::did::Did; 15 + use jacquard::types::handle::Handle; 16 + use jacquard::types::ident::AtIdentifier; 17 + use jacquard::types::nsid::Nsid; 18 + use jacquard::types::recordkey::{RecordKey, Rkey}; 19 + use jacquard::xrpc::XrpcClient; 20 + use jacquard::IntoStatic; 21 + use serde::{Deserialize, Serialize}; 22 + use serde_json::Value; 23 + use std::path::PathBuf; 24 + use tauri::{AppHandle, Emitter, Manager}; 25 + 26 + pub const EXPLORER_NAVIGATION_EVENT: &str = "navigation:explorer-resolved"; 27 + const PDS_REPO_LIST_LIMIT: i64 = 100; 28 + const QUERY_LABELS_LIMIT: i64 = 100; 29 + 30 + type ExplorerClient = Agent<UnauthenticatedSession<JacquardResolver>>; 31 + 32 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 33 + #[serde(rename_all = "camelCase")] 34 + pub enum ExplorerInputKind { 35 + AtUri, 36 + Handle, 37 + Did, 38 + PdsUrl, 39 + } 40 + 41 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 42 + #[serde(rename_all = "camelCase")] 43 + pub enum ExplorerTargetKind { 44 + Pds, 45 + Repo, 46 + Collection, 47 + Record, 48 + } 49 + 50 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 51 + #[serde(rename_all = "camelCase")] 52 + pub struct ResolvedExplorerInput { 53 + pub input: String, 54 + pub input_kind: ExplorerInputKind, 55 + pub target_kind: ExplorerTargetKind, 56 + pub normalized_input: String, 57 + pub uri: Option<String>, 58 + pub did: Option<String>, 59 + pub handle: Option<String>, 60 + pub pds_url: Option<String>, 61 + pub collection: Option<String>, 62 + pub rkey: Option<String>, 63 + } 64 + 65 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 66 + #[serde(rename_all = "camelCase")] 67 + pub struct ExplorerNavigation { 68 + pub target: ResolvedExplorerInput, 69 + } 70 + 71 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 72 + #[serde(rename_all = "camelCase")] 73 + pub struct ExplorerHostedRepo { 74 + pub did: String, 75 + pub head: String, 76 + pub rev: String, 77 + pub active: bool, 78 + pub status: Option<String>, 79 + } 80 + 81 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 82 + #[serde(rename_all = "camelCase")] 83 + pub struct ExplorerServerView { 84 + pub pds_url: String, 85 + pub server: Value, 86 + pub repos: Vec<ExplorerHostedRepo>, 87 + pub cursor: Option<String>, 88 + } 89 + 90 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 91 + #[serde(rename_all = "camelCase")] 92 + pub struct RepoCarExport { 93 + pub did: String, 94 + pub path: String, 95 + pub bytes_written: usize, 96 + } 97 + 98 + pub async fn resolve_input(input: String) -> Result<ResolvedExplorerInput> { 99 + let trimmed = input.trim(); 100 + if trimmed.is_empty() { 101 + return Err(AppError::validation("explorer input cannot be empty")); 102 + } 103 + 104 + match detect_input_kind(trimmed)? { 105 + ExplorerInputKind::AtUri => resolve_at_uri_input(trimmed).await, 106 + ExplorerInputKind::Handle => resolve_handle_input(trimmed).await, 107 + ExplorerInputKind::Did => resolve_did_input(trimmed).await, 108 + ExplorerInputKind::PdsUrl => Ok(ResolvedExplorerInput { 109 + input: trimmed.to_string(), 110 + input_kind: ExplorerInputKind::PdsUrl, 111 + target_kind: ExplorerTargetKind::Pds, 112 + normalized_input: normalize_pds_url(trimmed)?, 113 + uri: None, 114 + did: None, 115 + handle: None, 116 + pds_url: Some(normalize_pds_url(trimmed)?), 117 + collection: None, 118 + rkey: None, 119 + }), 120 + } 121 + } 122 + 123 + pub async fn describe_server(pds_url: String) -> Result<ExplorerServerView> { 124 + let normalized_pds_url = normalize_pds_url(&pds_url)?; 125 + let client = client_for_base_uri(&normalized_pds_url).await?; 126 + 127 + let server_output = client 128 + .send(DescribeServer) 129 + .await 130 + .map_err(|error| AppError::validation(format!("describeServer request failed: {error}")))? 131 + .into_output() 132 + .map_err(|error| AppError::validation(format!("describeServer output failed: {error}")))? 133 + .into_static(); 134 + 135 + let repo_output = client 136 + .send(ListRepos::new().limit(PDS_REPO_LIST_LIMIT).build()) 137 + .await 138 + .map_err(|error| AppError::validation(format!("listRepos request failed: {error}")))? 139 + .into_output() 140 + .map_err(|error| AppError::validation(format!("listRepos output failed: {error}")))? 141 + .into_static(); 142 + 143 + let repos = repo_output 144 + .repos 145 + .into_iter() 146 + .map(|repo| ExplorerHostedRepo { 147 + did: repo.did.to_string(), 148 + head: repo.head.to_string(), 149 + rev: repo.rev.to_string(), 150 + active: repo.active.unwrap_or(true), 151 + status: repo.status.map(|status| status.to_string()), 152 + }) 153 + .collect(); 154 + 155 + Ok(ExplorerServerView { 156 + pds_url: normalized_pds_url, 157 + server: serde_json::to_value(&server_output)?, 158 + repos, 159 + cursor: repo_output.cursor.map(|cursor| cursor.to_string()), 160 + }) 161 + } 162 + 163 + pub async fn describe_repo(did: String) -> Result<Value> { 164 + let output = describe_repo_output(&did).await?; 165 + serde_json::to_value(output).map_err(AppError::from) 166 + } 167 + 168 + pub async fn list_records(did: String, collection: String, cursor: Option<String>) -> Result<Value> { 169 + let client = public_client(); 170 + let request = ListRecords::new() 171 + .repo(parse_at_identifier(&did)?) 172 + .collection(parse_collection(&collection)?) 173 + .maybe_cursor(cursor.map(Into::into)) 174 + .build(); 175 + 176 + let output = client 177 + .send(request) 178 + .await 179 + .map_err(|error| AppError::validation(format!("listRecords request failed: {error}")))? 180 + .into_output() 181 + .map_err(|error| AppError::validation(format!("listRecords output failed: {error}")))? 182 + .into_static(); 183 + 184 + serde_json::to_value(output).map_err(AppError::from) 185 + } 186 + 187 + pub async fn get_record(did: String, collection: String, rkey: String) -> Result<Value> { 188 + let client = public_client(); 189 + let request = GetRecord::new() 190 + .repo(parse_at_identifier(&did)?) 191 + .collection(parse_collection(&collection)?) 192 + .rkey(parse_record_key(&rkey)?) 193 + .build(); 194 + 195 + let output = client 196 + .send(request) 197 + .await 198 + .map_err(|error| AppError::validation(format!("getRecord request failed: {error}")))? 199 + .into_output() 200 + .map_err(|error| AppError::validation(format!("getRecord output failed: {error}")))? 201 + .into_static(); 202 + 203 + serde_json::to_value(output).map_err(AppError::from) 204 + } 205 + 206 + pub async fn export_repo_car(did: String, app: &AppHandle) -> Result<RepoCarExport> { 207 + let parsed_did = Did::new(&did)?.into_static(); 208 + let client = public_client(); 209 + let output = client 210 + .send(GetRepo::new().did(parsed_did.clone()).build()) 211 + .await 212 + .map_err(|error| AppError::validation(format!("getRepo request failed: {error}")))? 213 + .into_output() 214 + .map_err(|error| AppError::validation(format!("getRepo output failed: {error}")))?; 215 + 216 + let export_path = resolve_car_export_path(app, parsed_did.as_str())?; 217 + if let Some(parent) = export_path.parent() { 218 + std::fs::create_dir_all(parent)?; 219 + } 220 + std::fs::write(&export_path, &output.body)?; 221 + 222 + Ok(RepoCarExport { 223 + did: parsed_did.to_string(), 224 + path: export_path.to_string_lossy().into_owned(), 225 + bytes_written: output.body.len(), 226 + }) 227 + } 228 + 229 + pub async fn query_labels(uri: String) -> Result<Value> { 230 + let normalized_uri = normalize_at_uri(&uri)?; 231 + let client = public_client(); 232 + let output = client 233 + .send( 234 + QueryLabels::new() 235 + .uri_patterns(vec![normalized_uri.into()]) 236 + .limit(QUERY_LABELS_LIMIT) 237 + .build(), 238 + ) 239 + .await 240 + .map_err(|error| AppError::validation(format!("queryLabels request failed: {error}")))? 241 + .into_output() 242 + .map_err(|error| AppError::validation(format!("queryLabels output failed: {error}")))? 243 + .into_static(); 244 + 245 + serde_json::to_value(output).map_err(AppError::from) 246 + } 247 + 248 + pub async fn emit_explorer_navigation(app: &AppHandle, raw: &str) -> Result<()> { 249 + let target = resolve_input(raw.to_string()).await?; 250 + app.emit(EXPLORER_NAVIGATION_EVENT, ExplorerNavigation { target })?; 251 + Ok(()) 252 + } 253 + 254 + fn public_client() -> ExplorerClient { 255 + Agent::new(UnauthenticatedSession::new_public()) 256 + } 257 + 258 + async fn client_for_base_uri(base_uri: &str) -> Result<ExplorerClient> { 259 + let client = public_client(); 260 + let normalized = Uri::parse(base_uri)?; 261 + client.set_base_uri(normalized.to_owned()).await; 262 + Ok(client) 263 + } 264 + 265 + async fn resolve_at_uri_input(input: &str) -> Result<ResolvedExplorerInput> { 266 + let parsed = AtUri::new(input)?; 267 + let (did, handle) = match parsed.authority() { 268 + AtIdentifier::Did(did) => (did.to_string(), None), 269 + AtIdentifier::Handle(handle) => (resolve_handle_to_did(handle.as_str()).await?, Some(handle.to_string())), 270 + }; 271 + let repo_metadata = describe_repo_metadata(&did).await?; 272 + 273 + Ok(build_resolved_at_uri( 274 + input, 275 + &did, 276 + handle.or(repo_metadata.handle), 277 + repo_metadata.pds_url, 278 + &parsed, 279 + )) 280 + } 281 + 282 + async fn resolve_handle_input(input: &str) -> Result<ResolvedExplorerInput> { 283 + let normalized_handle = normalize_handle(input).ok_or_else(|| AppError::validation("invalid handle input"))?; 284 + let did = resolve_handle_to_did(&normalized_handle).await?; 285 + let repo_metadata = describe_repo_metadata(&did).await?; 286 + 287 + Ok(ResolvedExplorerInput { 288 + input: input.trim().to_string(), 289 + input_kind: ExplorerInputKind::Handle, 290 + target_kind: ExplorerTargetKind::Repo, 291 + normalized_input: did.clone(), 292 + uri: Some(format!("at://{did}")), 293 + did: Some(did), 294 + handle: repo_metadata.handle.or(Some(normalized_handle)), 295 + pds_url: repo_metadata.pds_url, 296 + collection: None, 297 + rkey: None, 298 + }) 299 + } 300 + 301 + async fn resolve_did_input(input: &str) -> Result<ResolvedExplorerInput> { 302 + let did = Did::new(input.trim())?.to_string(); 303 + let repo_metadata = describe_repo_metadata(&did).await?; 304 + 305 + Ok(ResolvedExplorerInput { 306 + input: input.trim().to_string(), 307 + input_kind: ExplorerInputKind::Did, 308 + target_kind: ExplorerTargetKind::Repo, 309 + normalized_input: did.clone(), 310 + uri: Some(format!("at://{did}")), 311 + did: Some(did), 312 + handle: repo_metadata.handle, 313 + pds_url: repo_metadata.pds_url, 314 + collection: None, 315 + rkey: None, 316 + }) 317 + } 318 + 319 + async fn describe_repo_output( 320 + repo: &str, 321 + ) -> Result<jacquard::api::com_atproto::repo::describe_repo::DescribeRepoOutput<'static>> { 322 + let client = public_client(); 323 + client 324 + .send(DescribeRepo::new().repo(parse_at_identifier(repo)?).build()) 325 + .await 326 + .map_err(|error| AppError::validation(format!("describeRepo request failed: {error}")))? 327 + .into_output() 328 + .map_err(|error| AppError::validation(format!("describeRepo output failed: {error}"))) 329 + .map(IntoStatic::into_static) 330 + } 331 + 332 + #[derive(Debug, Clone, Default)] 333 + struct RepoMetadata { 334 + handle: Option<String>, 335 + pds_url: Option<String>, 336 + } 337 + 338 + async fn describe_repo_metadata(did: &str) -> Result<RepoMetadata> { 339 + let output = describe_repo_output(did).await?; 340 + let did_doc = serde_json::to_value(&output.did_doc)?; 341 + 342 + Ok(RepoMetadata { handle: Some(output.handle.to_string()), pds_url: extract_pds_url_from_did_doc_json(&did_doc) }) 343 + } 344 + 345 + async fn resolve_handle_to_did(handle: &str) -> Result<String> { 346 + let client = public_client(); 347 + client 348 + .send(ResolveHandle::new().handle(Handle::new(handle)?.into_static()).build()) 349 + .await 350 + .map_err(|error| AppError::validation(format!("resolveHandle request failed: {error}")))? 351 + .into_output() 352 + .map_err(|error| AppError::validation(format!("resolveHandle output failed: {error}"))) 353 + .map(|output| output.did.to_string()) 354 + } 355 + 356 + fn build_resolved_at_uri( 357 + input: &str, did: &str, handle: Option<String>, pds_url: Option<String>, parsed: &AtUri<'_>, 358 + ) -> ResolvedExplorerInput { 359 + let collection = parsed.collection().map(|collection| collection.to_string()); 360 + let rkey = parsed.rkey().map(|rkey| rkey.as_ref().to_string()); 361 + let target_kind = match (collection.as_ref(), rkey.as_ref()) { 362 + (Some(_), Some(_)) => ExplorerTargetKind::Record, 363 + (Some(_), None) => ExplorerTargetKind::Collection, 364 + (None, None) => ExplorerTargetKind::Repo, 365 + (None, Some(_)) => ExplorerTargetKind::Repo, 366 + }; 367 + let normalized_input = canonical_at_uri(did, collection.as_deref(), rkey.as_deref()); 368 + 369 + ResolvedExplorerInput { 370 + input: input.trim().to_string(), 371 + input_kind: ExplorerInputKind::AtUri, 372 + target_kind, 373 + normalized_input: normalized_input.clone(), 374 + uri: Some(normalized_input), 375 + did: Some(did.to_string()), 376 + handle, 377 + pds_url, 378 + collection, 379 + rkey, 380 + } 381 + } 382 + 383 + fn detect_input_kind(input: &str) -> Result<ExplorerInputKind> { 384 + let trimmed = input.trim(); 385 + 386 + if trimmed.starts_with("at://") { 387 + normalize_at_uri(trimmed)?; 388 + return Ok(ExplorerInputKind::AtUri); 389 + } 390 + 391 + if normalize_handle(trimmed).is_some() { 392 + return Ok(ExplorerInputKind::Handle); 393 + } 394 + 395 + if Did::new(trimmed).is_ok() { 396 + return Ok(ExplorerInputKind::Did); 397 + } 398 + 399 + if looks_like_http_url(trimmed) { 400 + normalize_pds_url(trimmed)?; 401 + return Ok(ExplorerInputKind::PdsUrl); 402 + } 403 + 404 + Err(AppError::validation( 405 + "explorer input must be an at:// URI, handle, DID, or PDS URL", 406 + )) 407 + } 408 + 409 + fn normalize_at_uri(input: &str) -> Result<String> { 410 + Ok(AtUri::new(input)?.to_string()) 411 + } 412 + 413 + fn normalize_handle(input: &str) -> Option<String> { 414 + let trimmed = input.trim().trim_start_matches('@'); 415 + if trimmed.is_empty() { 416 + return None; 417 + } 418 + 419 + Handle::new(trimmed).ok().map(|handle| handle.to_string()) 420 + } 421 + 422 + fn looks_like_http_url(input: &str) -> bool { 423 + input.starts_with("http://") || input.starts_with("https://") 424 + } 425 + 426 + fn normalize_pds_url(input: &str) -> Result<String> { 427 + let mut url = 428 + reqwest::Url::parse(input.trim()).map_err(|error| AppError::validation(format!("invalid PDS URL: {error}")))?; 429 + 430 + match url.scheme() { 431 + "http" | "https" => {} 432 + scheme => return Err(AppError::validation(format!("unsupported PDS URL scheme: {scheme}"))), 433 + } 434 + 435 + if url.host_str().is_none() { 436 + return Err(AppError::validation("PDS URL must include a host")); 437 + } 438 + 439 + url.set_path(""); 440 + url.set_query(None); 441 + url.set_fragment(None); 442 + 443 + Ok(url.to_string().trim_end_matches('/').to_string()) 444 + } 445 + 446 + fn canonical_at_uri(did: &str, collection: Option<&str>, rkey: Option<&str>) -> String { 447 + match (collection, rkey) { 448 + (Some(collection), Some(rkey)) => format!("at://{did}/{collection}/{rkey}"), 449 + (Some(collection), None) => format!("at://{did}/{collection}"), 450 + _ => format!("at://{did}"), 451 + } 452 + } 453 + 454 + fn parse_at_identifier(value: &str) -> Result<AtIdentifier<'static>> { 455 + AtIdentifier::new(value) 456 + .map(IntoStatic::into_static) 457 + .map_err(AppError::from) 458 + } 459 + 460 + fn parse_collection(collection: &str) -> Result<Nsid<'static>> { 461 + Nsid::new(collection) 462 + .map(IntoStatic::into_static) 463 + .map_err(AppError::from) 464 + } 465 + 466 + fn parse_record_key(rkey: &str) -> Result<RecordKey<Rkey<'static>>> { 467 + RecordKey::any(rkey) 468 + .map(IntoStatic::into_static) 469 + .map_err(AppError::from) 470 + } 471 + 472 + fn extract_pds_url_from_did_doc_json(did_doc: &Value) -> Option<String> { 473 + did_doc 474 + .get("service") 475 + .and_then(Value::as_array) 476 + .and_then(|services| { 477 + services.iter().find_map(|service| { 478 + let service_type = service.get("type").and_then(Value::as_str)?; 479 + if service_type != "AtprotoPersonalDataServer" { 480 + return None; 481 + } 482 + 483 + match service.get("serviceEndpoint") { 484 + Some(Value::String(url)) => Some(url.clone()), 485 + Some(Value::Object(object)) => object.get("url").and_then(Value::as_str).map(str::to_owned), 486 + _ => None, 487 + } 488 + }) 489 + }) 490 + .and_then(|url| normalize_pds_url(&url).ok()) 491 + } 492 + 493 + fn resolve_car_export_path(app: &AppHandle, did: &str) -> Result<PathBuf> { 494 + let mut app_data_dir = app 495 + .path() 496 + .app_data_dir() 497 + .map_err(|error| AppError::PathResolve(error.to_string()))?; 498 + app_data_dir.push("exports"); 499 + app_data_dir.push(repo_car_filename(did)); 500 + Ok(app_data_dir) 501 + } 502 + 503 + fn repo_car_filename(did: &str) -> String { 504 + format!("{}.car", sanitize_did_for_filename(did)) 505 + } 506 + 507 + fn sanitize_did_for_filename(did: &str) -> String { 508 + did.chars() 509 + .map(|character| match character { 510 + 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => character, 511 + _ => '_', 512 + }) 513 + .collect() 514 + } 515 + 516 + #[cfg(test)] 517 + mod tests { 518 + use super::{ 519 + canonical_at_uri, detect_input_kind, extract_pds_url_from_did_doc_json, normalize_handle, normalize_pds_url, 520 + repo_car_filename, sanitize_did_for_filename, ExplorerInputKind, 521 + }; 522 + use jacquard::types::aturi::AtUri; 523 + 524 + #[test] 525 + fn detects_all_supported_input_kinds() { 526 + assert_eq!( 527 + detect_input_kind("at://did:plc:alice/app.bsky.feed.post/123").expect("at uri should detect"), 528 + ExplorerInputKind::AtUri 529 + ); 530 + assert_eq!( 531 + detect_input_kind("@alice.bsky.social").expect("handle should detect"), 532 + ExplorerInputKind::Handle 533 + ); 534 + assert_eq!( 535 + detect_input_kind("did:plc:alice123").expect("did should detect"), 536 + ExplorerInputKind::Did 537 + ); 538 + assert_eq!( 539 + detect_input_kind("https://pds.example.com/xrpc/com.atproto.server.describeServer") 540 + .expect("pds url should detect"), 541 + ExplorerInputKind::PdsUrl 542 + ); 543 + } 544 + 545 + #[test] 546 + fn normalizes_handles_and_pds_urls() { 547 + assert_eq!( 548 + normalize_handle("@alice.bsky.social").expect("handle should normalize"), 549 + "alice.bsky.social" 550 + ); 551 + assert_eq!( 552 + normalize_pds_url("https://pds.example.com/xrpc/com.atproto.server.describeServer?foo=bar#hash") 553 + .expect("pds url should normalize"), 554 + "https://pds.example.com" 555 + ); 556 + } 557 + 558 + #[test] 559 + fn canonicalizes_at_uri_targets() { 560 + assert_eq!(canonical_at_uri("did:plc:alice", None, None), "at://did:plc:alice"); 561 + assert_eq!( 562 + canonical_at_uri("did:plc:alice", Some("app.bsky.feed.post"), None), 563 + "at://did:plc:alice/app.bsky.feed.post" 564 + ); 565 + assert_eq!( 566 + canonical_at_uri("did:plc:alice", Some("app.bsky.feed.post"), Some("abc123")), 567 + "at://did:plc:alice/app.bsky.feed.post/abc123" 568 + ); 569 + } 570 + 571 + #[test] 572 + fn extracts_pds_url_from_did_doc_shapes() { 573 + let string_endpoint = serde_json::json!({ 574 + "service": [ 575 + { 576 + "type": "AtprotoPersonalDataServer", 577 + "serviceEndpoint": "https://pds.example.com/" 578 + } 579 + ] 580 + }); 581 + let object_endpoint = serde_json::json!({ 582 + "service": [ 583 + { 584 + "type": "AtprotoPersonalDataServer", 585 + "serviceEndpoint": { 586 + "url": "https://pds.object.example.com/xrpc" 587 + } 588 + } 589 + ] 590 + }); 591 + 592 + assert_eq!( 593 + extract_pds_url_from_did_doc_json(&string_endpoint), 594 + Some("https://pds.example.com".to_string()) 595 + ); 596 + assert_eq!( 597 + extract_pds_url_from_did_doc_json(&object_endpoint), 598 + Some("https://pds.object.example.com".to_string()) 599 + ); 600 + } 601 + 602 + #[test] 603 + fn repo_car_filenames_are_filesystem_safe() { 604 + assert_eq!(sanitize_did_for_filename("did:plc:alice-123"), "did_plc_alice-123"); 605 + assert_eq!(repo_car_filename("did:plc:alice-123"), "did_plc_alice-123.car"); 606 + } 607 + 608 + #[test] 609 + fn at_uri_parser_distinguishes_repo_collection_and_record_levels() { 610 + let repo_uri = AtUri::new("at://did:plc:alice").expect("repo uri should parse"); 611 + let collection_uri = AtUri::new("at://did:plc:alice/app.bsky.feed.post").expect("collection uri should parse"); 612 + let record_uri = AtUri::new("at://did:plc:alice/app.bsky.feed.post/abc123").expect("record uri should parse"); 613 + 614 + assert!(repo_uri.collection().is_none()); 615 + assert!(repo_uri.rkey().is_none()); 616 + assert_eq!( 617 + collection_uri.collection().expect("collection should exist").as_str(), 618 + "app.bsky.feed.post" 619 + ); 620 + assert!(collection_uri.rkey().is_none()); 621 + assert_eq!(record_uri.rkey().expect("rkey should exist").as_ref(), "abc123"); 622 + } 623 + }
+19 -4
src-tauri/src/lib.rs
··· 2 2 mod commands; 3 3 mod db; 4 4 mod error; 5 + mod explorer; 5 6 mod feed; 6 7 mod notifications; 7 8 mod state; 8 9 mod tray; 9 10 10 - use auth::emit_at_uri_navigation; 11 11 use commands as cmd; 12 12 use db::initialize_database; 13 13 use state::AppState; 14 14 use tauri::Manager; 15 15 use tauri_plugin_deep_link::DeepLinkExt; 16 + use tauri_plugin_log::log; 16 17 17 18 #[cfg_attr(mobile, tauri::mobile_entry_point)] 18 19 pub fn run() { ··· 31 32 let app_handle = app.handle().clone(); 32 33 app.deep_link().on_open_url(move |event| { 33 34 for url in event.urls() { 34 - let _ = emit_at_uri_navigation(&app_handle, url.as_str()); 35 + let raw = url.to_string(); 36 + let handle = app_handle.clone(); 37 + tauri::async_runtime::spawn(async move { 38 + if let Err(error) = explorer::emit_explorer_navigation(&handle, &raw).await { 39 + log::error!("failed to resolve deep-link explorer target for {raw}: {error}"); 40 + } 41 + }); 35 42 } 36 43 }); 37 44 38 45 if let Some(urls) = app.deep_link().get_current()? { 39 46 for url in urls { 40 - emit_at_uri_navigation(app.handle(), url.as_str())?; 47 + let raw = url.to_string(); 48 + tauri::async_runtime::block_on(explorer::emit_explorer_navigation(app.handle(), &raw))?; 41 49 } 42 50 } 43 51 ··· 82 90 cmd::update_feed_view_pref, 83 91 cmd::list_notifications, 84 92 cmd::update_seen, 85 - cmd::get_unread_count 93 + cmd::get_unread_count, 94 + cmd::explorer::resolve_input, 95 + cmd::explorer::describe_server, 96 + cmd::explorer::describe_repo, 97 + cmd::explorer::list_records, 98 + cmd::explorer::get_record, 99 + cmd::explorer::export_repo_car, 100 + cmd::explorer::query_labels 86 101 ]) 87 102 .run(tauri::generate_context!()) 88 103 .expect("error while running tauri application");