Add configurable iPhone widgets
This commit is contained in:
@@ -7,7 +7,8 @@ use std::{
|
||||
|
||||
use gotcha_gitea::{Client, Provider};
|
||||
use security_framework::passwords::{
|
||||
delete_generic_password, get_generic_password, set_generic_password,
|
||||
PasswordOptions, delete_generic_password_options, generic_password, get_generic_password,
|
||||
set_generic_password_options,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
@@ -52,14 +53,27 @@ pub fn validate_server(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_preferences() -> Result<Preferences, String> {
|
||||
let path = preferences_path()?;
|
||||
if !path.exists() {
|
||||
return Ok(Preferences::default());
|
||||
}
|
||||
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(&path).map_err(|error| error.to_string())?)
|
||||
.map_err(|error| format!("Cannot read {}: {error}", path.display()))?;
|
||||
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();
|
||||
@@ -73,13 +87,10 @@ pub fn load_preferences() -> Result<Preferences, String> {
|
||||
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))?;
|
||||
server.token = String::from_utf8(load_server_token(server)?)
|
||||
.map_err(|_| format!("The token for {} is not valid text.", server.name))?;
|
||||
}
|
||||
if favorites_migrated || credentials_migrated {
|
||||
if storage_migrated || favorites_migrated || credentials_migrated {
|
||||
save_preferences(&preferences)?;
|
||||
}
|
||||
Ok(preferences)
|
||||
@@ -103,7 +114,11 @@ fn migrate_favorites(favorites: &mut std::collections::BTreeSet<String>) -> bool
|
||||
}
|
||||
|
||||
pub fn save_preferences(preferences: &Preferences) -> Result<(), String> {
|
||||
let path = preferences_path()?;
|
||||
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");
|
||||
@@ -116,16 +131,12 @@ pub fn save_preferences(preferences: &Preferences) -> Result<(), String> {
|
||||
}
|
||||
|
||||
pub fn save_server_token(server: &Server) -> Result<(), String> {
|
||||
set_generic_password(
|
||||
"de.rfc1437.gotcha",
|
||||
&keychain_account(server),
|
||||
server.token.as_bytes(),
|
||||
)
|
||||
.map_err(|error| format!("Cannot save the token for {}: {error}", server.name))
|
||||
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("de.rfc1437.gotcha", &keychain_account(server)) {
|
||||
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
|
||||
@@ -161,11 +172,42 @@ pub fn assign_server_credential_account(server: &mut Server, existing: &[Server]
|
||||
.expect("a unique Keychain account must exist");
|
||||
}
|
||||
|
||||
fn preferences_path() -> Result<PathBuf, String> {
|
||||
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)
|
||||
@@ -271,4 +313,35 @@ mod tests {
|
||||
);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user