Add configurable iPhone widgets
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -4,3 +4,4 @@ mod milestones;
|
||||
mod pulls;
|
||||
mod repositories;
|
||||
mod servers;
|
||||
mod widgets;
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
55
crates/app/src/core/widgets.rs
Normal file
55
crates/app/src/core/widgets.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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