348 lines
12 KiB
Rust
348 lines
12 KiB
Rust
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<Server, String> {
|
|
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<Preferences, String> {
|
|
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<String>) -> 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<PathBuf, String> {
|
|
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<Vec<u8>, 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::<Preferences>(r#"{"appearance":"dark"}"#)
|
|
.unwrap()
|
|
.appearance,
|
|
crate::domain::AppearanceMode::Dark
|
|
);
|
|
assert_eq!(
|
|
serde_json::from_str::<Preferences>(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::<Preferences>(&stored)
|
|
.unwrap()
|
|
.pull_status,
|
|
"closed"
|
|
);
|
|
|
|
fs::remove_file(path).unwrap();
|
|
fs::remove_dir(directory).unwrap();
|
|
}
|
|
}
|