use std::{ env, fs, path::PathBuf, process, time::{SystemTime, UNIX_EPOCH}, }; use gotcha_gitea::{Client, Provider}; use security_framework::passwords::{ PasswordOptions, delete_generic_password_options, generic_password, get_generic_password, set_generic_password_options, }; use crate::{ RepositoryPane, domain::{Preferences, Server, open_status}, }; pub fn favorite_key(pane: RepositoryPane, server: &str, owner: &str, repository: &str) -> String { format!( "{}|{}", pane.key(), repository_key(server, owner, repository) ) } pub fn repository_key(server: &str, owner: &str, repository: &str) -> String { format!("{server}|{owner}/{repository}") } pub fn validate_server( name: &str, url: &str, token: &str, provider: Provider, ) -> Result { let name = name.trim(); let url = url.trim().trim_end_matches('/'); let token = token.trim(); if name.is_empty() { return Err("Give this server a name.".into()); } if token.is_empty() { return Err("Enter an access token.".into()); } Client::with_provider(url, Some(token), provider).map_err(|error| error.to_string())?; Ok(Server { name: name.into(), url: url.into(), provider, credential_account: String::new(), token: token.into(), }) } const APP_GROUP: &str = "group.de.rfc1437.gotcha"; const KEYCHAIN_SERVICE: &str = "de.rfc1437.gotcha"; pub fn load_preferences(storage_directory: Option<&str>) -> Result { let path = preferences_path(storage_directory)?; let legacy = preferences_path(None)?; let source = if path.exists() { path.clone() } else if path != legacy && legacy.exists() { legacy } else { return Ok(Preferences { path, ..Preferences::default() }); }; let mut preferences: Preferences = serde_json::from_slice(&fs::read(&source).map_err(|error| error.to_string())?) .map_err(|error| format!("Cannot read {}: {error}", source.display()))?; preferences.path = path; let storage_migrated = source != preferences.path; let favorites_migrated = migrate_favorites(&mut preferences.favorites); if !matches!(preferences.issue_status.as_str(), "open" | "closed") { preferences.issue_status = open_status(); } if !matches!(preferences.pull_status.as_str(), "open" | "closed") { preferences.pull_status = open_status(); } let mut credentials_migrated = false; for server in &mut preferences.servers { if server.credential_account.is_empty() { server.credential_account = format!("{}|{}", server.name, server.url); credentials_migrated = true; } server.token = String::from_utf8(load_server_token(server)?) .map_err(|_| format!("The token for {} is not valid text.", server.name))?; } if storage_migrated || favorites_migrated || credentials_migrated { save_preferences(&preferences)?; } Ok(preferences) } fn migrate_favorites(favorites: &mut std::collections::BTreeSet) -> bool { let legacy: Vec<_> = favorites .iter() .filter(|favorite| { !RepositoryPane::ALL .iter() .any(|pane| favorite.starts_with(&format!("{}|", pane.key()))) }) .cloned() .collect(); for favorite in &legacy { favorites.remove(favorite); favorites.extend(RepositoryPane::ALL.map(|pane| format!("{}|{favorite}", pane.key()))); } !legacy.is_empty() } pub fn save_preferences(preferences: &Preferences) -> Result<(), String> { let path = if preferences.path.as_os_str().is_empty() { preferences_path(None)? } else { preferences.path.clone() }; let parent = path.parent().ok_or("Invalid app data directory.")?; fs::create_dir_all(parent).map_err(|error| error.to_string())?; let temporary = path.with_extension("tmp"); fs::write( &temporary, serde_json::to_vec(preferences).map_err(|error| error.to_string())?, ) .map_err(|error| error.to_string())?; fs::rename(&temporary, &path).map_err(|error| error.to_string()) } pub fn save_server_token(server: &Server) -> Result<(), String> { set_generic_password_options(server.token.as_bytes(), shared_password_options(server)) .map_err(|error| format!("Cannot save the token for {}: {error}", server.name)) } pub fn delete_server_token(server: &Server) -> Result<(), String> { match delete_generic_password_options(shared_password_options(server)) { Ok(()) => Ok(()), // A missing Keychain item must not make an otherwise valid profile undeletable. Err(error) if error.code() == -25300 => Ok(()), // errSecItemNotFound Err(error) => Err(format!( "Cannot delete the token for {}: {error}", server.name )), } } pub fn assign_server_credential_account(server: &mut Server, existing: &[Server]) { if !server.credential_account.is_empty() { return; } let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_nanos(); let prefix = format!("server-{}-{timestamp}", process::id()); server.credential_account = (0_u32..) .map(|suffix| { if suffix == 0 { prefix.clone() } else { format!("{prefix}-{suffix}") } }) .find(|candidate| { existing .iter() .all(|saved| saved.credential_account != *candidate) }) .expect("a unique Keychain account must exist"); } fn preferences_path(storage_directory: Option<&str>) -> Result { if let Some(directory) = storage_directory { let directory = directory.trim(); if directory.is_empty() { return Err("Invalid shared app data directory.".into()); } return Ok(PathBuf::from(directory).join("preferences.json")); } let home = env::var_os("HOME").ok_or("Cannot find the app data directory.")?; Ok(PathBuf::from(home).join("Library/Application Support/Gotcha/preferences.json")) } fn load_server_token(server: &Server) -> Result, String> { match generic_password(shared_password_options(server)) { Ok(token) => Ok(token), Err(error) if error.code() == -25300 => { let token = get_generic_password(KEYCHAIN_SERVICE, &keychain_account(server)) .map_err(|error| format!("Cannot read the token for {}: {error}", server.name))?; set_generic_password_options(&token, shared_password_options(server)) .map_err(|error| format!("Cannot share the token for {}: {error}", server.name))?; Ok(token) } Err(error) => Err(format!( "Cannot read the token for {}: {error}", server.name )), } } fn shared_password_options(server: &Server) -> PasswordOptions { let mut options = PasswordOptions::new_generic_password(KEYCHAIN_SERVICE, &keychain_account(server)); options.set_access_group(APP_GROUP); options } fn keychain_account(server: &Server) -> String { if server.credential_account.is_empty() { format!("{}|{}", server.name, server.url) } else { server.credential_account.clone() } } #[cfg(test)] mod tests { use super::*; #[test] fn validates_servers_and_builds_stable_favorite_keys() { assert!( validate_server( "Work", "https://gitea.example.com/", "secret", Provider::Gitea ) .is_ok() ); assert!( validate_server("", "https://gitea.example.com", "secret", Provider::Gitea).is_err() ); assert!(validate_server("Work", "file:///tmp/gitea", "secret", Provider::Forgejo).is_err()); assert_eq!( favorite_key( RepositoryPane::Issues, "https://gitea.example.com", "octo", "demo" ), "issues|https://gitea.example.com|octo/demo" ); let preferences: Preferences = serde_json::from_str( r#"{"issue_filters":{"server|octo/demo":{"milestone":"v1","labels":["bug"]}},"pull_filters":{"server":{"milestone":"v2"}}}"#, ) .unwrap(); assert_eq!( preferences.issue_filters["server|octo/demo"].milestone, "v1" ); assert!( preferences.issue_filters["server|octo/demo"] .labels .contains("bug") ); assert_eq!(preferences.pull_filters["server"].milestone, "v2"); assert_eq!(Preferences::default().pull_status, "open"); assert_eq!( Preferences::default().appearance, crate::domain::AppearanceMode::Auto ); assert_eq!( serde_json::from_str::(r#"{"appearance":"dark"}"#) .unwrap() .appearance, crate::domain::AppearanceMode::Dark ); assert_eq!( serde_json::from_str::(r#"{"appearance":"future-mode"}"#) .unwrap() .appearance, crate::domain::AppearanceMode::Auto ); let legacy_server: Server = serde_json::from_str(r#"{"name":"Work","url":"https://gitea.example.com"}"#).unwrap(); assert_eq!(legacy_server.provider, Provider::Gitea); assert!(legacy_server.credential_account.is_empty()); assert_eq!( keychain_account(&legacy_server), "Work|https://gitea.example.com" ); let mut new_server = legacy_server.clone(); assign_server_credential_account(&mut new_server, &[]); assert!(new_server.credential_account.starts_with("server-")); assert_eq!(keychain_account(&new_server), new_server.credential_account); assert_eq!( crate::domain::AppearanceMode::from_index(1), Some(crate::domain::AppearanceMode::Light) ); assert_eq!(crate::domain::AppearanceMode::from_index(3), None); } #[test] fn migrates_global_favorites_to_each_repository_pane() { let mut favorites = ["https://gitea.example.com|octo/demo".to_string()] .into_iter() .collect(); assert!(migrate_favorites(&mut favorites)); assert_eq!( favorites, [ "commits|https://gitea.example.com|octo/demo".to_string(), "issues|https://gitea.example.com|octo/demo".to_string(), "milestones|https://gitea.example.com|octo/demo".to_string(), ] .into_iter() .collect() ); assert!(!migrate_favorites(&mut favorites)); } #[test] fn saves_preferences_to_the_selected_storage_directory() { let directory = env::temp_dir().join(format!( "gotcha-preferences-{}-{}", process::id(), SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos() )); let path = directory.join("preferences.json"); let preferences = Preferences { path: path.clone(), pull_status: "closed".into(), ..Preferences::default() }; save_preferences(&preferences).unwrap(); let stored = fs::read_to_string(&path).unwrap(); assert!(!stored.contains(path.to_string_lossy().as_ref())); assert_eq!( serde_json::from_str::(&stored) .unwrap() .pull_status, "closed" ); fs::remove_file(path).unwrap(); fs::remove_dir(directory).unwrap(); } }