Add server editing and deletion
This commit is contained in:
@@ -60,6 +60,20 @@ impl GotchaCore {
|
||||
.map(|server| server.name.clone())
|
||||
}
|
||||
|
||||
pub fn server_editor(&self, index: u32) -> Result<ServerEditor, GotchaError> {
|
||||
let state = self.state.lock().unwrap();
|
||||
let server = state
|
||||
.preferences
|
||||
.servers
|
||||
.get(index as usize)
|
||||
.ok_or("That server no longer exists.")?;
|
||||
Ok(ServerEditor {
|
||||
name: server.name.clone(),
|
||||
url: server.url.clone(),
|
||||
provider: server.provider.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn select_server(&self, index: u32) -> Result<(), GotchaError> {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let index = index as usize;
|
||||
@@ -88,16 +102,121 @@ impl GotchaCore {
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
server.provider = client.provider();
|
||||
save_server_token(&server)?;
|
||||
let mut state = self.state.lock().unwrap();
|
||||
state.preferences.servers.push(server);
|
||||
let index = state.preferences.servers.len() - 1;
|
||||
state.preferences.last_server = Some(index);
|
||||
assign_server_credential_account(&mut server, &state.preferences.servers);
|
||||
save_server_token(&server)?;
|
||||
let mut preferences = state.preferences.clone();
|
||||
preferences.servers.push(server.clone());
|
||||
let index = preferences.servers.len() - 1;
|
||||
preferences.last_server = Some(index);
|
||||
if let Err(error) = save_preferences(&preferences) {
|
||||
return Err(rollback_added_server_token(&server, error).into());
|
||||
}
|
||||
state.preferences = preferences;
|
||||
state.active_server = Some(index);
|
||||
save_preferences(&state.preferences)?;
|
||||
Ok(index as u32)
|
||||
}
|
||||
|
||||
pub async fn update_server(
|
||||
&self,
|
||||
index: u32,
|
||||
name: String,
|
||||
url: String,
|
||||
token: String,
|
||||
provider: ServerProvider,
|
||||
) -> Result<(), GotchaError> {
|
||||
let index = index as usize;
|
||||
let old_server = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.preferences
|
||||
.servers
|
||||
.get(index)
|
||||
.cloned()
|
||||
.ok_or("That server no longer exists.")?;
|
||||
let token = if token.trim().is_empty() {
|
||||
old_server.token.clone()
|
||||
} else {
|
||||
token
|
||||
};
|
||||
let mut server = validate_server(&name, &url, &token, provider.into())?;
|
||||
server.credential_account = old_server.credential_account.clone();
|
||||
let client = Client::discover(&server.url, Some(&server.token), server.provider)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
client
|
||||
.current_user()
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
server.provider = client.provider();
|
||||
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let current = state
|
||||
.preferences
|
||||
.servers
|
||||
.get(index)
|
||||
.ok_or("That server no longer exists.")?;
|
||||
if current.credential_account != old_server.credential_account {
|
||||
return Err("That server changed while it was being verified.".into());
|
||||
}
|
||||
save_server_token(&server)?;
|
||||
let mut preferences = state.preferences.clone();
|
||||
preferences.servers[index] = server;
|
||||
if old_server.url != preferences.servers[index].url
|
||||
&& preferences
|
||||
.servers
|
||||
.iter()
|
||||
.all(|saved| saved.url != old_server.url)
|
||||
{
|
||||
let new_url = preferences.servers[index].url.clone();
|
||||
migrate_server_settings(&mut preferences, &old_server.url, &new_url);
|
||||
}
|
||||
if let Err(error) = save_preferences(&preferences) {
|
||||
return Err(rollback_updated_server_token(&old_server, error).into());
|
||||
}
|
||||
state.preferences = preferences;
|
||||
state.repositories.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_server(&self, index: u32) -> Result<(), GotchaError> {
|
||||
let index = index as usize;
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let server = state
|
||||
.preferences
|
||||
.servers
|
||||
.get(index)
|
||||
.cloned()
|
||||
.ok_or("That server no longer exists.")?;
|
||||
let mut preferences = state.preferences.clone();
|
||||
preferences.servers.remove(index);
|
||||
let active_server =
|
||||
active_server_after_removal(state.active_server, index, preferences.servers.len());
|
||||
preferences.last_server = active_server;
|
||||
if preferences
|
||||
.servers
|
||||
.iter()
|
||||
.all(|saved| saved.url != server.url)
|
||||
{
|
||||
remove_server_settings(&mut preferences, &server.url);
|
||||
}
|
||||
save_preferences(&preferences)?;
|
||||
if let Err(error) = delete_server_token(&server) {
|
||||
if let Err(rollback) = save_preferences(&state.preferences) {
|
||||
return Err(format!(
|
||||
"{error} Restoring the server configuration also failed: {rollback}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
state.preferences = preferences;
|
||||
state.active_server = active_server;
|
||||
state.repositories.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn settings(&self) -> Settings {
|
||||
let state = self.state.lock().unwrap();
|
||||
Settings {
|
||||
@@ -146,3 +265,154 @@ impl GotchaCore {
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn rollback_added_server_token(server: &Server, error: String) -> String {
|
||||
match delete_server_token(server) {
|
||||
Ok(()) => error,
|
||||
Err(rollback) => format!("{error} Removing the unused token also failed: {rollback}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn rollback_updated_server_token(server: &Server, error: String) -> String {
|
||||
match save_server_token(server) {
|
||||
Ok(()) => error,
|
||||
Err(rollback) => format!("{error} Restoring the previous token also failed: {rollback}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn active_server_after_removal(
|
||||
active: Option<usize>,
|
||||
removed: usize,
|
||||
remaining: usize,
|
||||
) -> Option<usize> {
|
||||
match active {
|
||||
_ if remaining == 0 => None,
|
||||
Some(active) if active > removed => Some(active - 1),
|
||||
Some(active) if active == removed => Some(removed.min(remaining - 1)),
|
||||
active => active,
|
||||
}
|
||||
}
|
||||
|
||||
fn migrate_server_settings(preferences: &mut Preferences, old_url: &str, new_url: &str) {
|
||||
for pane in RepositoryPane::ALL {
|
||||
let old_prefix = format!("{}|{old_url}|", pane.key());
|
||||
let new_prefix = format!("{}|{new_url}|", pane.key());
|
||||
let moved: Vec<_> = preferences
|
||||
.favorites
|
||||
.iter()
|
||||
.filter_map(|favorite| {
|
||||
favorite
|
||||
.strip_prefix(&old_prefix)
|
||||
.map(|suffix| (favorite.clone(), format!("{new_prefix}{suffix}")))
|
||||
})
|
||||
.collect();
|
||||
for (old, new) in moved {
|
||||
preferences.favorites.remove(&old);
|
||||
preferences.favorites.insert(new);
|
||||
}
|
||||
}
|
||||
|
||||
let old_prefix = format!("{old_url}|");
|
||||
let new_prefix = format!("{new_url}|");
|
||||
let issue_filters: Vec<_> = preferences
|
||||
.issue_filters
|
||||
.iter()
|
||||
.filter_map(|(key, filter)| {
|
||||
key.strip_prefix(&old_prefix)
|
||||
.map(|suffix| (key.clone(), format!("{new_prefix}{suffix}"), filter.clone()))
|
||||
})
|
||||
.collect();
|
||||
for (old, new, filter) in issue_filters {
|
||||
preferences.issue_filters.remove(&old);
|
||||
preferences.issue_filters.entry(new).or_insert(filter);
|
||||
}
|
||||
if let Some(filter) = preferences.pull_filters.remove(old_url) {
|
||||
preferences
|
||||
.pull_filters
|
||||
.entry(new_url.into())
|
||||
.or_insert(filter);
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_server_settings(preferences: &mut Preferences, url: &str) {
|
||||
preferences.favorites.retain(|favorite| {
|
||||
RepositoryPane::ALL
|
||||
.iter()
|
||||
.all(|pane| !favorite.starts_with(&format!("{}|{url}|", pane.key())))
|
||||
});
|
||||
let prefix = format!("{url}|");
|
||||
preferences
|
||||
.issue_filters
|
||||
.retain(|key, _| !key.starts_with(&prefix));
|
||||
preferences.pull_filters.remove(url);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn server(name: &str, url: &str) -> Server {
|
||||
Server {
|
||||
name: name.into(),
|
||||
url: url.into(),
|
||||
provider: gotcha_gitea::Provider::Gitea,
|
||||
credential_account: name.into(),
|
||||
token: "secret".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjusts_selection_when_servers_are_removed() {
|
||||
assert_eq!(active_server_after_removal(Some(0), 0, 0), None);
|
||||
assert_eq!(active_server_after_removal(Some(0), 0, 2), Some(0));
|
||||
assert_eq!(active_server_after_removal(Some(2), 1, 2), Some(1));
|
||||
assert_eq!(active_server_after_removal(Some(0), 2, 2), Some(0));
|
||||
assert_eq!(active_server_after_removal(None, 0, 1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrates_and_removes_server_scoped_settings() {
|
||||
let old = "https://old.example.com";
|
||||
let new = "https://new.example.com";
|
||||
let mut preferences = Preferences {
|
||||
servers: vec![server("Work", old)],
|
||||
favorites: [format!("issues|{old}|octo/demo")].into_iter().collect(),
|
||||
issue_filters: [(
|
||||
format!("{old}|octo/demo"),
|
||||
IssueFilter {
|
||||
milestone: "v1".into(),
|
||||
..Default::default()
|
||||
},
|
||||
)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
pull_filters: [(
|
||||
old.into(),
|
||||
PullFilter {
|
||||
milestone: "v2".into(),
|
||||
..Default::default()
|
||||
},
|
||||
)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
migrate_server_settings(&mut preferences, old, new);
|
||||
assert!(
|
||||
preferences
|
||||
.favorites
|
||||
.contains(&format!("issues|{new}|octo/demo"))
|
||||
);
|
||||
assert_eq!(
|
||||
preferences.issue_filters[&format!("{new}|octo/demo")].milestone,
|
||||
"v1"
|
||||
);
|
||||
assert_eq!(preferences.pull_filters[new].milestone, "v2");
|
||||
|
||||
remove_server_settings(&mut preferences, new);
|
||||
assert!(preferences.favorites.is_empty());
|
||||
assert!(preferences.issue_filters.is_empty());
|
||||
assert!(preferences.pull_filters.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,11 +41,13 @@ pub struct Server {
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub provider: gotcha_gitea::Provider,
|
||||
#[serde(default)]
|
||||
pub credential_account: String,
|
||||
#[serde(skip)]
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
pub struct Preferences {
|
||||
#[serde(default)]
|
||||
pub servers: Vec<Server>,
|
||||
|
||||
@@ -36,6 +36,15 @@ impl From<ServerProvider> for gotcha_gitea::Provider {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<gotcha_gitea::Provider> for ServerProvider {
|
||||
fn from(provider: gotcha_gitea::Provider) -> Self {
|
||||
match provider {
|
||||
gotcha_gitea::Provider::Gitea => Self::Gitea,
|
||||
gotcha_gitea::Provider::Forgejo => Self::Forgejo,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RepositoryPane {
|
||||
const ALL: [Self; 3] = [Self::Issues, Self::Commits, Self::Milestones];
|
||||
|
||||
|
||||
@@ -20,6 +20,13 @@ pub struct ServerRow {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct ServerEditor {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub provider: crate::ServerProvider,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct RepositoryRow {
|
||||
pub name: String,
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
use std::{env, fs, path::PathBuf};
|
||||
use std::{
|
||||
env, fs,
|
||||
path::PathBuf,
|
||||
process,
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use gotcha_gitea::{Client, Provider};
|
||||
use security_framework::passwords::{get_generic_password, set_generic_password};
|
||||
use security_framework::passwords::{
|
||||
delete_generic_password, get_generic_password, set_generic_password,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
RepositoryPane,
|
||||
@@ -40,6 +47,7 @@ pub fn validate_server(
|
||||
name: name.into(),
|
||||
url: url.into(),
|
||||
provider,
|
||||
credential_account: String::new(),
|
||||
token: token.into(),
|
||||
})
|
||||
}
|
||||
@@ -59,14 +67,19 @@ pub fn load_preferences() -> Result<Preferences, String> {
|
||||
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(
|
||||
get_generic_password("de.rfc1437.gotcha", &keychain_account(server))
|
||||
.map_err(|error| format!("Cannot read the token for {}: {error}", server.name))?,
|
||||
)
|
||||
.map_err(|_| format!("The token for {} is not valid text.", server.name))?;
|
||||
}
|
||||
if favorites_migrated {
|
||||
if favorites_migrated || credentials_migrated {
|
||||
save_preferences(&preferences)?;
|
||||
}
|
||||
Ok(preferences)
|
||||
@@ -111,13 +124,54 @@ pub fn save_server_token(server: &Server) -> Result<(), String> {
|
||||
.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("de.rfc1437.gotcha", &keychain_account(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() -> Result<PathBuf, String> {
|
||||
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 keychain_account(server: &Server) -> String {
|
||||
format!("{}|{}", server.name, server.url)
|
||||
if server.credential_account.is_empty() {
|
||||
format!("{}|{}", server.name, server.url)
|
||||
} else {
|
||||
server.credential_account.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -182,6 +236,15 @@ mod tests {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user