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

@@ -423,6 +423,13 @@ pub async fn load_home(
client(server)?.home(page, filter).await.map_err(message)
}
pub async fn load_activities(server: &Server, page: i32) -> Result<Page<models::Activity>, String> {
client(server)?
.activities(page, gotcha_gitea::ActivityFilter::All)
.await
.map_err(message)
}
fn client(server: &Server) -> Result<Client, String> {
Client::with_provider(&server.url, Some(&server.token), server.provider).map_err(message)
}

View File

@@ -4,3 +4,4 @@ mod milestones;
mod pulls;
mod repositories;
mod servers;
mod widgets;

View File

@@ -7,8 +7,8 @@ use crate::*;
#[uniffi::export(async_runtime = "tokio")]
impl GotchaCore {
#[uniffi::constructor]
pub fn new() -> Arc<Self> {
let (preferences, startup_error) = match load_preferences() {
pub fn new(storage_directory: Option<String>) -> Arc<Self> {
let (preferences, startup_error) = match load_preferences(storage_directory.as_deref()) {
Ok(preferences) => (preferences, None),
Err(error) => (Preferences::default(), Some(error)),
};
@@ -38,6 +38,7 @@ impl GotchaCore {
.servers
.iter()
.map(|server| ServerRow {
id: server.credential_account.clone(),
name: server.name.clone(),
url: server.url.clone(),
})

View File

@@ -0,0 +1,55 @@
use crate::*;
const MAX_WIDGET_ROWS: u32 = 10;
#[uniffi::export(async_runtime = "tokio")]
impl GotchaCore {
pub async fn widget_activity(
&self,
server_id: String,
limit: u32,
) -> Result<WidgetActivityPage, GotchaError> {
let server = self.server_by_id(&server_id)?;
let mut rows = activity_rows(&load_activities(&server, 1).await?.items);
rows.truncate(widget_limit(limit)?);
Ok(WidgetActivityPage {
server_name: server.name,
rows,
})
}
pub async fn widget_pulls(
&self,
server_id: String,
limit: u32,
) -> Result<WidgetPullPage, GotchaError> {
let server = self.server_by_id(&server_id)?;
let pulls = load_pulls(&server, "open", "", "", 1).await?;
let mut rows = pull_rows(&pulls.items);
rows.truncate(widget_limit(limit)?);
Ok(WidgetPullPage {
server_name: server.name,
rows,
})
}
}
fn widget_limit(limit: u32) -> Result<usize, GotchaError> {
(1..=MAX_WIDGET_ROWS)
.contains(&limit)
.then_some(limit as usize)
.ok_or_else(|| "Widget row count must be between 1 and 10.".into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_widget_row_limits() {
assert_eq!(widget_limit(1).unwrap(), 1);
assert_eq!(widget_limit(10).unwrap(), 10);
assert!(widget_limit(0).is_err());
assert!(widget_limit(11).is_err());
}
}

View File

@@ -1,4 +1,7 @@
use std::collections::{BTreeMap, BTreeSet};
use std::{
collections::{BTreeMap, BTreeSet},
path::PathBuf,
};
pub use gotcha_gitea::{
HistoryCommit, HomeData, IssueDetails, IssueEditorData, MilestoneDetails, Page, PullDetails,
@@ -49,6 +52,8 @@ pub struct Server {
#[derive(Clone, Deserialize, Serialize)]
pub struct Preferences {
#[serde(skip)]
pub path: PathBuf,
#[serde(default)]
pub servers: Vec<Server>,
#[serde(default)]
@@ -70,6 +75,7 @@ pub struct Preferences {
impl Default for Preferences {
fn default() -> Self {
Self {
path: PathBuf::new(),
servers: Vec::new(),
favorites: BTreeSet::new(),
last_server: None,

View File

@@ -117,6 +117,18 @@ impl GotchaCore {
.ok_or_else(|| "Select a server first.".to_string().into())
}
fn server_by_id(&self, id: &str) -> Result<Server, GotchaError> {
self.state
.lock()
.unwrap()
.preferences
.servers
.iter()
.find(|server| server.credential_account == id)
.cloned()
.ok_or_else(|| "That server is no longer configured.".into())
}
fn repository_rows(&self, state: &State, pane: RepositoryPane) -> Vec<RepositoryRow> {
let server_url = state
.active_server

View File

@@ -16,6 +16,7 @@ pub enum WorkItemState {
#[derive(Clone, uniffi::Record)]
pub struct ServerRow {
pub id: String,
pub name: String,
pub url: String,
}
@@ -358,6 +359,18 @@ pub struct HomePage {
pub next_page: Option<u32>,
}
#[derive(Clone, uniffi::Record)]
pub struct WidgetActivityPage {
pub server_name: String,
pub rows: Vec<ActivityRow>,
}
#[derive(Clone, uniffi::Record)]
pub struct WidgetPullPage {
pub server_name: String,
pub rows: Vec<PullRow>,
}
mod details;
mod files;
mod helpers;

View File

@@ -4,13 +4,17 @@ pub fn home_page(server_name: String, home: HomeData) -> HomePage {
let (heat_cells, contribution_count) = heat_cells(&home.heatmap);
HomePage {
server_name,
activities: home.activities.iter().map(activity_row).collect(),
activities: activity_rows(&home.activities),
heat_cells,
contribution_count,
next_page: home.next_page.map(|page| page as u32),
}
}
pub fn activity_rows(activities: &[models::Activity]) -> Vec<ActivityRow> {
activities.iter().map(activity_row).collect()
}
fn activity_row(activity: &models::Activity) -> ActivityRow {
use models::activity::OpType;

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

View File

@@ -1,6 +1,6 @@
use crate::{
Client, Error, Result,
domain::{DEFAULT_PAGE_SIZE, HomeData},
domain::{DEFAULT_PAGE_SIZE, HomeData, Page},
models,
};
use gitea_openapi::apis;
@@ -71,6 +71,23 @@ pub enum Target {
}
impl Client {
pub async fn activities(
&self,
page: i32,
filter: ActivityFilter,
) -> Result<Page<models::Activity>> {
if page < 1 {
return Err(Error::InvalidInput("page must be positive".into()));
}
let configuration = self.configuration();
let login = self
.current_user()
.await?
.login
.ok_or_else(|| Error::Generated("The server account has no username.".into()))?;
filtered_activity_page(&configuration, &login, page, filter).await
}
pub async fn home(&self, page: i32, filter: ActivityFilter) -> Result<HomeData> {
if page < 1 {
return Err(Error::InvalidInput("page must be positive".into()));
@@ -81,31 +98,41 @@ impl Client {
.await?
.login
.ok_or_else(|| Error::Generated("The server account has no username.".into()))?;
let activities = activity_page(&configuration, &login, page);
let (activities, heatmap) = tokio::join!(
activities,
filtered_activity_page(&configuration, &login, page, filter),
apis::user_api::user_get_heatmap_data(&configuration, &login),
);
let mut activities = activities?;
let heatmap = heatmap.map_err(Error::generated)?;
let mut page = page;
let activities = activities?;
Ok(HomeData {
activities: activities.items,
heatmap: heatmap.map_err(Error::generated)?,
next_page: activities.has_more.then_some(page + 1),
})
}
}
loop {
let has_more = activities.len() == DEFAULT_PAGE_SIZE as usize;
let filtered: Vec<_> = activities
.into_iter()
.filter(|activity| filter.matches(activity))
.collect();
if filter == ActivityFilter::All || !filtered.is_empty() || !has_more {
return Ok(HomeData {
activities: filtered,
heatmap,
next_page: has_more.then_some(page + 1),
});
}
page += 1;
activities = activity_page(&configuration, &login, page).await?;
async fn filtered_activity_page(
configuration: &apis::configuration::Configuration,
login: &str,
mut page: i32,
filter: ActivityFilter,
) -> Result<Page<models::Activity>> {
let mut activities = activity_page(configuration, login, page).await?;
loop {
let has_more = activities.len() == DEFAULT_PAGE_SIZE as usize;
let filtered: Vec<_> = activities
.into_iter()
.filter(|activity| filter.matches(activity))
.collect();
if filter == ActivityFilter::All || !filtered.is_empty() || !has_more {
return Ok(Page {
items: filtered,
has_more,
});
}
page += 1;
activities = activity_page(configuration, login, page).await?;
}
}