Add configurable iPhone widgets

This commit is contained in:
Georg Bauer
2026-08-03 22:06:32 +02:00
parent 2534010d1d
commit c67d274a95
23 changed files with 1151 additions and 56 deletions

View File

@@ -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();
}
}