diff --git a/TESTING.md b/TESTING.md index f49bc19..3c6aa3f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -230,6 +230,30 @@ answer before uploading it to App Store Connect. matching native tab and destination. - [ ] Non-linkable server activity does not navigate or appear tappable. +## Home-screen widgets + +- [ ] Upgrade over a build with an existing server. Launch Gotcha once, then + add both **Recent Activity** and **Open Pull Requests** from the system + widget gallery; the saved server and Keychain token work without re-entry. +- [ ] Both widgets are offered only in the large system size and use native + widget margins, typography, tint, relative update time, and light/dark + appearances without clipping at the largest accessibility text size. +- [ ] Recent Activity shows up to six latest activity rows for its configured + server. Open Pull Requests shows up to five open pull requests. Loading, + empty, missing-server, and API-error states remain legible and do not + expose account data while the device is locked. +- [ ] Add a second server profile, long-press each widget, choose **Edit + Widget**, and assign a different server to each. The displayed server + name and rows update independently; deleting a selected server changes + that widget to its missing-server state instead of showing another + server's data. +- [ ] Tap Recent Activity; Gotcha opens or foregrounds at the Home root. Tap + Open Pull Requests; Gotcha opens or foregrounds at the PR root. Both + routes discard a stale detail stack in the destination tab. +- [ ] Add, edit, select, and delete server profiles in Gotcha, then return to + the Home Screen. Widget configuration choices and timelines refresh to + match the persisted server list. + ## Issues - [ ] The repository list shows name, description, language, open count, update diff --git a/crates/app/src/api.rs b/crates/app/src/api.rs index 78ecbab..27b1cfd 100644 --- a/crates/app/src/api.rs +++ b/crates/app/src/api.rs @@ -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, String> { + client(server)? + .activities(page, gotcha_gitea::ActivityFilter::All) + .await + .map_err(message) +} + fn client(server: &Server) -> Result { Client::with_provider(&server.url, Some(&server.token), server.provider).map_err(message) } diff --git a/crates/app/src/core/mod.rs b/crates/app/src/core/mod.rs index 7691915..c939ee8 100644 --- a/crates/app/src/core/mod.rs +++ b/crates/app/src/core/mod.rs @@ -4,3 +4,4 @@ mod milestones; mod pulls; mod repositories; mod servers; +mod widgets; diff --git a/crates/app/src/core/servers.rs b/crates/app/src/core/servers.rs index 00889eb..d19927c 100644 --- a/crates/app/src/core/servers.rs +++ b/crates/app/src/core/servers.rs @@ -7,8 +7,8 @@ use crate::*; #[uniffi::export(async_runtime = "tokio")] impl GotchaCore { #[uniffi::constructor] - pub fn new() -> Arc { - let (preferences, startup_error) = match load_preferences() { + pub fn new(storage_directory: Option) -> Arc { + 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(), }) diff --git a/crates/app/src/core/widgets.rs b/crates/app/src/core/widgets.rs new file mode 100644 index 0000000..4d33b5a --- /dev/null +++ b/crates/app/src/core/widgets.rs @@ -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 { + 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 { + 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 { + (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()); + } +} diff --git a/crates/app/src/domain.rs b/crates/app/src/domain.rs index 6dcc24a..ed1b6fd 100644 --- a/crates/app/src/domain.rs +++ b/crates/app/src/domain.rs @@ -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, #[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, diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 46bf915..2fc4b82 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -117,6 +117,18 @@ impl GotchaCore { .ok_or_else(|| "Select a server first.".to_string().into()) } + fn server_by_id(&self, id: &str) -> Result { + 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 { let server_url = state .active_server diff --git a/crates/app/src/presentation.rs b/crates/app/src/presentation.rs index 215990f..74aa690 100644 --- a/crates/app/src/presentation.rs +++ b/crates/app/src/presentation.rs @@ -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, } +#[derive(Clone, uniffi::Record)] +pub struct WidgetActivityPage { + pub server_name: String, + pub rows: Vec, +} + +#[derive(Clone, uniffi::Record)] +pub struct WidgetPullPage { + pub server_name: String, + pub rows: Vec, +} + mod details; mod files; mod helpers; diff --git a/crates/app/src/presentation/home.rs b/crates/app/src/presentation/home.rs index e577ead..4900eff 100644 --- a/crates/app/src/presentation/home.rs +++ b/crates/app/src/presentation/home.rs @@ -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 { + activities.iter().map(activity_row).collect() +} + fn activity_row(activity: &models::Activity) -> ActivityRow { use models::activity::OpType; diff --git a/crates/app/src/storage.rs b/crates/app/src/storage.rs index 3b6460e..79be903 100644 --- a/crates/app/src/storage.rs +++ b/crates/app/src/storage.rs @@ -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 { - 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 { + 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 { 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) -> 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 { +fn preferences_path(storage_directory: Option<&str>) -> Result { + 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, 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::(&stored) + .unwrap() + .pull_status, + "closed" + ); + + fs::remove_file(path).unwrap(); + fs::remove_dir(directory).unwrap(); + } } diff --git a/crates/gitea/src/activity.rs b/crates/gitea/src/activity.rs index 94c3fff..47253cb 100644 --- a/crates/gitea/src/activity.rs +++ b/crates/gitea/src/activity.rs @@ -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> { + 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 { 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> { + 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?; } } diff --git a/ios/Generated/gotcha_core.swift b/ios/Generated/gotcha_core.swift index b0e415b..654a162 100644 --- a/ios/Generated/gotcha_core.swift +++ b/ios/Generated/gotcha_core.swift @@ -693,6 +693,10 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func updateServer(index: UInt32, name: String, url: String, token: String, provider: ServerProvider) async throws + func widgetActivity(serverId: String, limit: UInt32) async throws -> WidgetActivityPage + + func widgetPulls(serverId: String, limit: UInt32) async throws -> WidgetPullPage + } open class GotchaCore: GotchaCoreProtocol, @unchecked Sendable { fileprivate let handle: UInt64 @@ -733,11 +737,12 @@ open class GotchaCore: GotchaCoreProtocol, @unchecked Sendable { public func uniffiCloneHandle() -> UInt64 { return try! rustCall { uniffi_gotcha_core_fn_clone_gotchacore(self.handle, $0) } } -public convenience init() { +public convenience init(storageDirectory: String?) { let handle = try! rustCall() { uniffiCallStatus in - uniffi_gotcha_core_fn_constructor_gotchacore_new(uniffiCallStatus + uniffi_gotcha_core_fn_constructor_gotchacore_new( + FfiConverterOptionString.lower(storageDirectory),uniffiCallStatus ) } self.init(unsafeFromHandle: handle) @@ -1367,6 +1372,38 @@ open func updateServer(index: UInt32, name: String, url: String, token: String, ) } +open func widgetActivity(serverId: String, limit: UInt32)async throws -> WidgetActivityPage { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_widget_activity( + self.uniffiCloneHandle(),FfiConverterString.lower(serverId),FfiConverterUInt32.lower(limit) + ) + }, + pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer, + completeFunc: ffi_gotcha_core_rust_future_complete_rust_buffer, + freeFunc: ffi_gotcha_core_rust_future_free_rust_buffer, + liftFunc: FfiConverterTypeWidgetActivityPage_lift, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + +open func widgetPulls(serverId: String, limit: UInt32)async throws -> WidgetPullPage { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_widget_pulls( + self.uniffiCloneHandle(),FfiConverterString.lower(serverId),FfiConverterUInt32.lower(limit) + ) + }, + pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer, + completeFunc: ffi_gotcha_core_rust_future_complete_rust_buffer, + freeFunc: ffi_gotcha_core_rust_future_free_rust_buffer, + liftFunc: FfiConverterTypeWidgetPullPage_lift, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + } @@ -3475,6 +3512,10 @@ public struct ServerEditor: Equatable, Hashable { self.url = url self.provider = provider } + + + + } #if compiler(>=6) @@ -3501,6 +3542,7 @@ public struct FfiConverterTypeServerEditor: FfiConverterRustBuffer { } } + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -3515,13 +3557,16 @@ public func FfiConverterTypeServerEditor_lower(_ value: ServerEditor) -> RustBuf return FfiConverterTypeServerEditor.lower(value) } + public struct ServerRow: Equatable, Hashable { + public var id: String public var name: String public var url: String // Default memberwise initializers are never public by default, so we // declare one manually. - public init(name: String, url: String) { + public init(id: String, name: String, url: String) { + self.id = id self.name = name self.url = url } @@ -3542,12 +3587,14 @@ public struct FfiConverterTypeServerRow: FfiConverterRustBuffer { public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> ServerRow { return try ServerRow( + id: FfiConverterString.read(from: &buf), name: FfiConverterString.read(from: &buf), url: FfiConverterString.read(from: &buf) ) } public static func write(_ value: ServerRow, into buf: inout [UInt8]) { + FfiConverterString.write(value.id, into: &buf) FfiConverterString.write(value.name, into: &buf) FfiConverterString.write(value.url, into: &buf) } @@ -3627,6 +3674,114 @@ public func FfiConverterTypeSettings_lower(_ value: Settings) -> RustBuffer { } +public struct WidgetActivityPage: Equatable, Hashable { + public var serverName: String + public var rows: [ActivityRow] + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(serverName: String, rows: [ActivityRow]) { + self.serverName = serverName + self.rows = rows + } + + + + +} + +#if compiler(>=6) +extension WidgetActivityPage: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeWidgetActivityPage: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> WidgetActivityPage { + return + try WidgetActivityPage( + serverName: FfiConverterString.read(from: &buf), + rows: FfiConverterSequenceTypeActivityRow.read(from: &buf) + ) + } + + public static func write(_ value: WidgetActivityPage, into buf: inout [UInt8]) { + FfiConverterString.write(value.serverName, into: &buf) + FfiConverterSequenceTypeActivityRow.write(value.rows, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeWidgetActivityPage_lift(_ buf: RustBuffer) throws -> WidgetActivityPage { + return try FfiConverterTypeWidgetActivityPage.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeWidgetActivityPage_lower(_ value: WidgetActivityPage) -> RustBuffer { + return FfiConverterTypeWidgetActivityPage.lower(value) +} + + +public struct WidgetPullPage: Equatable, Hashable { + public var serverName: String + public var rows: [PullRow] + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(serverName: String, rows: [PullRow]) { + self.serverName = serverName + self.rows = rows + } + + + + +} + +#if compiler(>=6) +extension WidgetPullPage: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeWidgetPullPage: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> WidgetPullPage { + return + try WidgetPullPage( + serverName: FfiConverterString.read(from: &buf), + rows: FfiConverterSequenceTypePullRow.read(from: &buf) + ) + } + + public static func write(_ value: WidgetPullPage, into buf: inout [UInt8]) { + FfiConverterString.write(value.serverName, into: &buf) + FfiConverterSequenceTypePullRow.write(value.rows, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeWidgetPullPage_lift(_ buf: RustBuffer) throws -> WidgetPullPage { + return try FfiConverterTypeWidgetPullPage.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeWidgetPullPage_lower(_ value: WidgetPullPage) -> RustBuffer { + return FfiConverterTypeWidgetPullPage.lower(value) +} + + public enum ActivityIcon: Equatable, Hashable { @@ -5145,7 +5300,13 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_update_server() != 31001) { return InitializationResult.apiChecksumMismatch } - if (uniffi_gotcha_core_checksum_constructor_gotchacore_new() != 35044) { + if (uniffi_gotcha_core_checksum_method_gotchacore_widget_activity() != 29431) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_gotcha_core_checksum_method_gotchacore_widget_pulls() != 23227) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_gotcha_core_checksum_constructor_gotchacore_new() != 64965) { return InitializationResult.apiChecksumMismatch } @@ -5165,4 +5326,4 @@ public func uniffiEnsureGotchaCoreInitialized() { } } -// swiftlint:enable all +// swiftlint:enable all \ No newline at end of file diff --git a/ios/Generated/gotcha_coreFFI.h b/ios/Generated/gotcha_coreFFI.h index 619b81d..53c8518 100644 --- a/ios/Generated/gotcha_coreFFI.h +++ b/ios/Generated/gotcha_coreFFI.h @@ -255,8 +255,7 @@ void uniffi_gotcha_core_fn_free_gotchacore(uint64_t handle, RustCallStatus *_Non #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_CONSTRUCTOR_GOTCHACORE_NEW #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_CONSTRUCTOR_GOTCHACORE_NEW -uint64_t uniffi_gotcha_core_fn_constructor_gotchacore_new(RustCallStatus *_Nonnull out_status - +uint64_t uniffi_gotcha_core_fn_constructor_gotchacore_new(RustBuffer storage_directory, RustCallStatus *_Nonnull out_status ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_COMMIT_DETAILS @@ -484,6 +483,16 @@ RustBuffer uniffi_gotcha_core_fn_method_gotchacore_startup_error(uint64_t ptr, R uint64_t uniffi_gotcha_core_fn_method_gotchacore_update_server(uint64_t ptr, uint32_t index, RustBuffer name, RustBuffer url, RustBuffer token, RustBuffer provider ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_WIDGET_ACTIVITY +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_WIDGET_ACTIVITY +uint64_t uniffi_gotcha_core_fn_method_gotchacore_widget_activity(uint64_t ptr, RustBuffer server_id, uint32_t limit +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_WIDGET_PULLS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_WIDGET_PULLS +uint64_t uniffi_gotcha_core_fn_method_gotchacore_widget_pulls(uint64_t ptr, RustBuffer server_id, uint32_t limit +); +#endif #ifndef UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUSTBUFFER_ALLOC #define UNIFFI_FFIDEF_FFI_GOTCHA_CORE_RUSTBUFFER_ALLOC RustBuffer ffi_gotcha_core_rustbuffer_alloc(uint64_t size, RustCallStatus *_Nonnull out_status @@ -1012,6 +1021,18 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_startup_error(void #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_UPDATE_SERVER uint16_t uniffi_gotcha_core_checksum_method_gotchacore_update_server(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_WIDGET_ACTIVITY +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_WIDGET_ACTIVITY +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_widget_activity(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_WIDGET_PULLS +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_WIDGET_PULLS +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_widget_pulls(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_CONSTRUCTOR_GOTCHACORE_NEW diff --git a/ios/Gotcha.entitlements b/ios/Gotcha.entitlements index 27dd610..b625435 100644 --- a/ios/Gotcha.entitlements +++ b/ios/Gotcha.entitlements @@ -2,6 +2,10 @@ + com.apple.security.application-groups + + group.de.rfc1437.gotcha + keychain-access-groups $(AppIdentifierPrefix)$(CFBundleIdentifier) diff --git a/ios/Gotcha.xcodeproj/project.pbxproj b/ios/Gotcha.xcodeproj/project.pbxproj index f3e767f..bed15fa 100644 --- a/ios/Gotcha.xcodeproj/project.pbxproj +++ b/ios/Gotcha.xcodeproj/project.pbxproj @@ -14,14 +14,19 @@ 267E72DD7E12E3082974337E /* IssueActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17264C5C909501D8ADEC00B7 /* IssueActions.swift */; }; 33D3E65C9E50522B2039816A /* PullScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99B8279189276A084B69D7D0 /* PullScreens.swift */; }; 381A27D70EA30C1A3BA1BBC1 /* CommentEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */; }; + 39C36B0D5FB260C920D466DC /* GotchaWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5121A6F14A9C9F6EA144BB2F /* GotchaWidgets.swift */; }; 4652515AE4CB10963D995143 /* CommitScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7588A44A93B8DB4B78C3B2BB /* CommitScreens.swift */; }; + 74626C144E9214BF89F821D2 /* WidgetIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77AE1D594C38D2BDAA2E86AD /* WidgetIntents.swift */; }; + 7A9D1ADD6623C89A7D5E016F /* GotchaWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 52ABD1B07CEEF00263038653 /* GotchaWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 7BBE64F66374221F1743BC24 /* IssueScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACD6449327E1A51FCD8E3BD4 /* IssueScreens.swift */; }; 7DD332583B169B3CA1CA46E0 /* Highlighter in Frameworks */ = {isa = PBXBuildFile; productRef = EC5F999F50905E3801E8A71A /* Highlighter */; }; 8A42B4319AF343566D77D12A /* WorkItemDetailScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BB99EB32ECA1F8C55293EE3 /* WorkItemDetailScreens.swift */; }; + 8BDF1E1259A237221FDDB822 /* WidgetIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77AE1D594C38D2BDAA2E86AD /* WidgetIntents.swift */; }; 950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C0921C76676A14A024BA417 /* AppDelegate.swift */; }; 96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */; }; 9B2206DF1263080B9B25B82C /* ServerScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13A39F3C39C1F353D58C307 /* ServerScreens.swift */; }; A2AF2C1F3C8B0716E1EF36B2 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = F121BE52F7C9C8780341F988 /* PrivacyInfo.xcprivacy */; }; + BDFE5D7A54541A109B35BF45 /* gotcha_core.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE12480725C13293FAFDDA52 /* gotcha_core.swift */; }; C33BA07C5F7DA72CBF72CEAE /* MilestoneScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64B529D84926EEEDC163A929 /* MilestoneScreens.swift */; }; CD50D82AC4B92576A2ADE163 /* CommitDetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81A55AAC9DB99FEB79F56DAF /* CommitDetailScreen.swift */; }; D2B9B033E92BF8E7C0BDCDB0 /* IssueEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722405F999984C7CBE838711 /* IssueEditorViewController.swift */; }; @@ -32,18 +37,46 @@ E7AC0B140F5CFC5EF226D174 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */; }; EC7F8B4703DDE37A0B10CD9B /* AppContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = F61515849F6AACD721FE915C /* AppContext.swift */; }; F55A89489B2758D694F3B27D /* Support.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75B3E4FFB9C9992517C4D69 /* Support.swift */; }; + FF6BD7B3AAE19FDB107AF9F8 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = F121BE52F7C9C8780341F988 /* PrivacyInfo.xcprivacy */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + EFB9473AB945D16F3AA3B49B /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = C30A6F1B592A907D96D6AB40 /* Project object */; + proxyType = 1; + remoteGlobalIDString = AC111BD5DA5739822FCD7926; + remoteInfo = GotchaWidgets; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + E567315EBFF59B424AE3BCC5 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 7A9D1ADD6623C89A7D5E016F /* GotchaWidgets.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 000E810FCB39B9916CA5CAB8 /* RepositoryListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryListScreen.swift; sourceTree = ""; }; 0BB99EB32ECA1F8C55293EE3 /* WorkItemDetailScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkItemDetailScreens.swift; sourceTree = ""; }; 17264C5C909501D8ADEC00B7 /* IssueActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueActions.swift; sourceTree = ""; }; 181AE294D07DB4EAC9C9A0FF /* RepositoryFileScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryFileScreens.swift; sourceTree = ""; }; 41E0F726C9668B50418D9ADF /* DiffScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffScreen.swift; sourceTree = ""; }; + 5121A6F14A9C9F6EA144BB2F /* GotchaWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GotchaWidgets.swift; sourceTree = ""; }; + 52ABD1B07CEEF00263038653 /* GotchaWidgets.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = GotchaWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 5FB3250A93766966A60A685E /* Gotcha.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Gotcha.app; sourceTree = BUILT_PRODUCTS_DIR; }; 64B529D84926EEEDC163A929 /* MilestoneScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MilestoneScreens.swift; sourceTree = ""; }; 722405F999984C7CBE838711 /* IssueEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueEditorViewController.swift; sourceTree = ""; }; 7588A44A93B8DB4B78C3B2BB /* CommitScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitScreens.swift; sourceTree = ""; }; + 77AE1D594C38D2BDAA2E86AD /* WidgetIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetIntents.swift; sourceTree = ""; }; 81A55AAC9DB99FEB79F56DAF /* CommitDetailScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitDetailScreen.swift; sourceTree = ""; }; 8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; 8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentEditorViewController.swift; sourceTree = ""; }; @@ -74,6 +107,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 1E34F71186DBE1669D42B546 /* Widgets */ = { + isa = PBXGroup; + children = ( + 5121A6F14A9C9F6EA144BB2F /* GotchaWidgets.swift */, + ); + path = Widgets; + sourceTree = ""; + }; 710A50F51478401FC642E6E3 /* Sources */ = { isa = PBXGroup; children = ( @@ -96,6 +137,7 @@ C13A39F3C39C1F353D58C307 /* ServerScreens.swift */, 8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */, F75B3E4FFB9C9992517C4D69 /* Support.swift */, + 77AE1D594C38D2BDAA2E86AD /* WidgetIntents.swift */, 0BB99EB32ECA1F8C55293EE3 /* WorkItemDetailScreens.swift */, ); path = Sources; @@ -116,6 +158,7 @@ F121BE52F7C9C8780341F988 /* PrivacyInfo.xcprivacy */, 94721140EFE7F8E7CD0F5C0B /* Generated */, 710A50F51478401FC642E6E3 /* Sources */, + 1E34F71186DBE1669D42B546 /* Widgets */, F059299C038F3CAFCE470831 /* Products */, ); sourceTree = ""; @@ -124,6 +167,7 @@ isa = PBXGroup; children = ( 5FB3250A93766966A60A685E /* Gotcha.app */, + 52ABD1B07CEEF00263038653 /* GotchaWidgets.appex */, ); name = Products; sourceTree = ""; @@ -139,10 +183,12 @@ 409DBF67E9C2743801B8F8A4 /* Sources */, 9EDD4380917C29EE67EE344B /* Resources */, 9BC71F568A96324A1942A0AC /* Frameworks */, + E567315EBFF59B424AE3BCC5 /* Embed Foundation Extensions */, ); buildRules = ( ); dependencies = ( + D934DE8051607244EA19B70A /* PBXTargetDependency */, ); name = Gotcha; packageProductDependencies = ( @@ -153,6 +199,25 @@ productReference = 5FB3250A93766966A60A685E /* Gotcha.app */; productType = "com.apple.product-type.application"; }; + AC111BD5DA5739822FCD7926 /* GotchaWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = 09AF074399FFB04451DE5C3A /* Build configuration list for PBXNativeTarget "GotchaWidgets" */; + buildPhases = ( + E451101CA0ABC24348B8641A /* Build Rust core */, + 3850C0D57620418DE0FDE303 /* Sources */, + C2E7E52485E196A093E8124C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = GotchaWidgets; + packageProductDependencies = ( + ); + productName = GotchaWidgets; + productReference = 52ABD1B07CEEF00263038653 /* GotchaWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -166,6 +231,10 @@ DevelopmentTeam = MU22FMRGK8; ProvisioningStyle = Automatic; }; + AC111BD5DA5739822FCD7926 = { + DevelopmentTeam = MU22FMRGK8; + ProvisioningStyle = Automatic; + }; }; }; buildConfigurationList = 807BC8918EC1B0F31384F2B7 /* Build configuration list for PBXProject "Gotcha" */; @@ -187,6 +256,7 @@ projectRoot = ""; targets = ( 60C9DF1AB4A7858833302BA5 /* Gotcha */, + AC111BD5DA5739822FCD7926 /* GotchaWidgets */, ); }; /* End PBXProject section */ @@ -201,6 +271,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + C2E7E52485E196A093E8124C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FF6BD7B3AAE19FDB107AF9F8 /* PrivacyInfo.xcprivacy in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -224,9 +302,39 @@ shellPath = /bin/sh; shellScript = "./build_rust_core.bash\n"; }; + E451101CA0ABC24348B8641A /* Build Rust core */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Build Rust core"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/rust/libgotcha_core.a", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "./build_rust_core.bash\n"; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 3850C0D57620418DE0FDE303 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 39C36B0D5FB260C920D466DC /* GotchaWidgets.swift in Sources */, + 8BDF1E1259A237221FDDB822 /* WidgetIntents.swift in Sources */, + BDFE5D7A54541A109B35BF45 /* gotcha_core.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 409DBF67E9C2743801B8F8A4 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -250,6 +358,7 @@ 9B2206DF1263080B9B25B82C /* ServerScreens.swift in Sources */, 96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */, F55A89489B2758D694F3B27D /* Support.swift in Sources */, + 74626C144E9214BF89F821D2 /* WidgetIntents.swift in Sources */, 8A42B4319AF343566D77D12A /* WorkItemDetailScreens.swift in Sources */, D59D3ED36ABCC1D8690A9088 /* gotcha_core.swift in Sources */, ); @@ -257,6 +366,14 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + D934DE8051607244EA19B70A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = AC111BD5DA5739822FCD7926 /* GotchaWidgets */; + targetProxy = EFB9473AB945D16F3AA3B49B /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 2F34E9C13F86A9A4A187A561 /* Debug */ = { isa = XCBuildConfiguration; @@ -373,6 +490,31 @@ }; name = Debug; }; + 9A0532DE2DD19A5F00EE4829 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = GotchaWidgets.entitlements; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = MU22FMRGK8; + INFOPLIST_FILE = "GotchaWidgets-Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LIBRARY_SEARCH_PATHS = "$(inherited) $(DERIVED_FILE_DIR)/rust"; + OTHER_LDFLAGS = "$(inherited) -lgotcha_core"; + PRODUCT_BUNDLE_IDENTIFIER = de.rfc1437.gotcha.widgets; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Gotcha-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; AA31619080EBF75C58A16A96 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -429,9 +571,43 @@ }; name = Release; }; + B2DBC3D9C80A2BC962A8FA91 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_ENTITLEMENTS = GotchaWidgets.entitlements; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = MU22FMRGK8; + INFOPLIST_FILE = "GotchaWidgets-Info.plist"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LIBRARY_SEARCH_PATHS = "$(inherited) $(DERIVED_FILE_DIR)/rust"; + OTHER_LDFLAGS = "$(inherited) -lgotcha_core"; + PRODUCT_BUNDLE_IDENTIFIER = de.rfc1437.gotcha.widgets; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_OBJC_BRIDGING_HEADER = "Gotcha-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 09AF074399FFB04451DE5C3A /* Build configuration list for PBXNativeTarget "GotchaWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 9A0532DE2DD19A5F00EE4829 /* Debug */, + B2DBC3D9C80A2BC962A8FA91 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; 7E8DF3DDA64C8C998FF3BB1D /* Build configuration list for PBXNativeTarget "Gotcha" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/ios/GotchaWidgets-Info.plist b/ios/GotchaWidgets-Info.plist new file mode 100644 index 0000000..97a8895 --- /dev/null +++ b/ios/GotchaWidgets-Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Gotcha Widgets + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + ITSAppUsesNonExemptEncryption + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/ios/GotchaWidgets.entitlements b/ios/GotchaWidgets.entitlements new file mode 100644 index 0000000..b625435 --- /dev/null +++ b/ios/GotchaWidgets.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + group.de.rfc1437.gotcha + + keychain-access-groups + + $(AppIdentifierPrefix)$(CFBundleIdentifier) + + + diff --git a/ios/Info.plist b/ios/Info.plist index 0c03d19..443d314 100644 --- a/ios/Info.plist +++ b/ios/Info.plist @@ -18,6 +18,17 @@ APPL CFBundleShortVersionString $(MARKETING_VERSION) + CFBundleURLTypes + + + CFBundleURLName + de.rfc1437.gotcha + CFBundleURLSchemes + + gotcha + + + CFBundleVersion $(CURRENT_PROJECT_VERSION) ITSAppUsesNonExemptEncryption diff --git a/ios/Sources/AppContext.swift b/ios/Sources/AppContext.swift index 7c3644e..edbd165 100644 --- a/ios/Sources/AppContext.swift +++ b/ios/Sources/AppContext.swift @@ -1,14 +1,19 @@ import UIKit +import WidgetKit @MainActor final class AppContext { - let core = GotchaCore() + let core: GotchaCore private let window: UIWindow private(set) var tabs = UITabBarController() private(set) var navigationControllers: [UINavigationController] = [] init(window: UIWindow) { self.window = window + let storageDirectory = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: "group.de.rfc1437.gotcha" + )?.path + core = GotchaCore(storageDirectory: storageDirectory) applyAppearance() } @@ -61,6 +66,21 @@ final class AppContext { for (index, root) in replacements { navigationControllers[index].setViewControllers([root], animated: false) } + WidgetCenter.shared.reloadAllTimelines() + } + + @discardableResult + func route(widgetURL: URL) -> Bool { + guard widgetURL.scheme == "gotcha" else { return false } + let index: Int + switch widgetURL.host { + case "home": index = 0 + case "pulls": index = 3 + default: return false + } + tabs.selectedIndex = index + navigationControllers[index].popToRootViewController(animated: false) + return true } func didAddServer(index: UInt32) throws { diff --git a/ios/Sources/AppDelegate.swift b/ios/Sources/AppDelegate.swift index 331693a..32cb00b 100644 --- a/ios/Sources/AppDelegate.swift +++ b/ios/Sources/AppDelegate.swift @@ -1,8 +1,10 @@ import UIKit +import WidgetKit @main final class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? + private var context: AppContext? func application( _ application: UIApplication, @@ -10,10 +12,26 @@ final class AppDelegate: UIResponder, UIApplicationDelegate { ) -> Bool { let window = UIWindow(frame: UIScreen.main.bounds) let context = AppContext(window: window) + self.context = context window.rootViewController = context.makeRootController() window.makeKeyAndVisible() self.window = window context.showStartupErrorIfNeeded() + if let url = launchOptions?[.url] as? URL { + context.route(widgetURL: url) + } return true } + + func application( + _ application: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] + ) -> Bool { + context?.route(widgetURL: url) ?? false + } + + func applicationDidBecomeActive(_ application: UIApplication) { + WidgetCenter.shared.reloadAllTimelines() + } } diff --git a/ios/Sources/WidgetIntents.swift b/ios/Sources/WidgetIntents.swift new file mode 100644 index 0000000..a7efd52 --- /dev/null +++ b/ios/Sources/WidgetIntents.swift @@ -0,0 +1,52 @@ +import AppIntents +import Foundation + +enum WidgetEnvironment { + static let appGroup = "group.de.rfc1437.gotcha" + + static func core() -> GotchaCore { + GotchaCore( + storageDirectory: FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroup + )?.path + ) + } + + static func servers() -> [ServerRow] { + core().servers() + } + + static func selection(for server: ServerRow) -> String { + "\(server.name) · \(server.url)" + } + + static func server(for selection: String) -> ServerRow? { + servers().first { self.selection(for: $0) == selection } + } +} + +struct WidgetServerOptionsProvider: DynamicOptionsProvider { + func results() async throws -> [String] { + WidgetEnvironment.servers().map(WidgetEnvironment.selection) + } + + func defaultResult() async -> String? { + WidgetEnvironment.servers().first.map(WidgetEnvironment.selection) + } +} + +struct ActivityWidgetIntent: WidgetConfigurationIntent { + static let title: LocalizedStringResource = "Activity Server" + static let description = IntentDescription("Choose the server whose recent activity appears.") + + @Parameter(title: "Server", optionsProvider: WidgetServerOptionsProvider()) + var server: String? +} + +struct PullsWidgetIntent: WidgetConfigurationIntent { + static let title: LocalizedStringResource = "Pull Request Server" + static let description = IntentDescription("Choose the server whose open pull requests appear.") + + @Parameter(title: "Server", optionsProvider: WidgetServerOptionsProvider()) + var server: String? +} diff --git a/ios/Widgets/GotchaWidgets.swift b/ios/Widgets/GotchaWidgets.swift new file mode 100644 index 0000000..fef0bb2 --- /dev/null +++ b/ios/Widgets/GotchaWidgets.swift @@ -0,0 +1,324 @@ +import SwiftUI +import WidgetKit + +private struct ActivityEntry: TimelineEntry { + let date: Date + let serverName: String + let rows: [ActivityRow] + let message: String? +} + +private struct PullsEntry: TimelineEntry { + let date: Date + let serverName: String + let rows: [PullRow] + let message: String? +} + +private struct ActivityProvider: AppIntentTimelineProvider { + func placeholder(in context: Context) -> ActivityEntry { + ActivityEntry( + date: .now, + serverName: "My Server", + rows: [ + ActivityRow( + icon: .push, + title: "Pushed to main in owner/project", + detail: "", + meta: "Just now", + target: .commit, + owner: "owner", + repository: "project", + number: 0, + sha: "" + ), + ActivityRow( + icon: .issue, + title: "Opened an issue in owner/project", + detail: "", + meta: "5m ago", + target: .issue, + owner: "owner", + repository: "project", + number: 1, + sha: "" + ), + ], + message: nil + ) + } + + func snapshot(for configuration: ActivityWidgetIntent, in context: Context) async -> ActivityEntry { + context.isPreview ? placeholder(in: context) : await load(configuration) + } + + func timeline(for configuration: ActivityWidgetIntent, in context: Context) async -> Timeline { + Timeline( + entries: [await load(configuration)], + policy: .after(Date.now.addingTimeInterval(15 * 60)) + ) + } + + private func load(_ configuration: ActivityWidgetIntent) async -> ActivityEntry { + guard + let selection = configuration.server, + let server = WidgetEnvironment.server(for: selection) + else { + return ActivityEntry( + date: .now, + serverName: "Activity", + rows: [], + message: "Add a server in Gotcha, then configure this widget." + ) + } + do { + let page = try await WidgetEnvironment.core().widgetActivity(serverId: server.id, limit: 6) + return ActivityEntry( + date: .now, + serverName: page.serverName, + rows: page.rows, + message: page.rows.isEmpty ? "No recent activity." : nil + ) + } catch { + return ActivityEntry( + date: .now, + serverName: server.name, + rows: [], + message: error.localizedDescription + ) + } + } +} + +private struct PullsProvider: AppIntentTimelineProvider { + func placeholder(in context: Context) -> PullsEntry { + PullsEntry( + date: .now, + serverName: "My Server", + rows: [ + PullRow( + number: 42, + owner: "owner", + repository: "project", + state: .open, + title: "project #42\nImprove the activity screen", + summary: "", + meta: "Open · developer · Just now" + ), + PullRow( + number: 41, + owner: "owner", + repository: "project", + state: .open, + title: "project #41\nFix repository navigation", + summary: "", + meta: "Open · developer · 10m ago" + ), + ], + message: nil + ) + } + + func snapshot(for configuration: PullsWidgetIntent, in context: Context) async -> PullsEntry { + context.isPreview ? placeholder(in: context) : await load(configuration) + } + + func timeline(for configuration: PullsWidgetIntent, in context: Context) async -> Timeline { + Timeline( + entries: [await load(configuration)], + policy: .after(Date.now.addingTimeInterval(15 * 60)) + ) + } + + private func load(_ configuration: PullsWidgetIntent) async -> PullsEntry { + guard + let selection = configuration.server, + let server = WidgetEnvironment.server(for: selection) + else { + return PullsEntry( + date: .now, + serverName: "Pull Requests", + rows: [], + message: "Add a server in Gotcha, then configure this widget." + ) + } + do { + let page = try await WidgetEnvironment.core().widgetPulls(serverId: server.id, limit: 5) + return PullsEntry( + date: .now, + serverName: page.serverName, + rows: page.rows, + message: page.rows.isEmpty ? "No open pull requests." : nil + ) + } catch { + return PullsEntry( + date: .now, + serverName: server.name, + rows: [], + message: error.localizedDescription + ) + } + } +} + +private struct WidgetHeader: View { + let title: String + let symbol: String + let server: String + let updated: Date + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Label(title, systemImage: symbol) + .font(.headline) + Spacer(minLength: 8) + VStack(alignment: .trailing, spacing: 1) { + Text(server) + .font(.caption.weight(.semibold)) + .lineLimit(1) + Text(updated, style: .relative) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } +} + +private struct ActivityWidgetView: View { + let entry: ActivityEntry + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + WidgetHeader( + title: "Activity", + symbol: "waveform.path.ecg", + server: entry.serverName, + updated: entry.date + ) + Divider() + if let message = entry.message { + emptyState(message, symbol: "clock.arrow.circlepath") + } else { + VStack(alignment: .leading, spacing: 7) { + ForEach(Array(entry.rows.enumerated()), id: \.offset) { _, row in + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: symbol(for: row.icon)) + .foregroundStyle(.tint) + .frame(width: 16) + Text(row.title) + .font(.caption) + .lineLimit(1) + Spacer(minLength: 4) + Text(row.meta) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .accessibilityElement(children: .combine) + } + } + .privacySensitive() + } + } + .containerBackground(.background, for: .widget) + .widgetURL(URL(string: "gotcha://home")) + } + + private func symbol(for icon: ActivityIcon) -> String { + switch icon { + case .repository: "shippingbox" + case .issue: "exclamationmark.circle" + case .pullRequest: "arrow.triangle.pull" + case .branch: "arrow.triangle.branch" + case .tag: "tag" + case .push: "arrow.up.circle" + case .release: "shippingbox.fill" + } + } +} + +private struct PullsWidgetView: View { + let entry: PullsEntry + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + WidgetHeader( + title: "Open Pull Requests", + symbol: "arrow.triangle.pull", + server: entry.serverName, + updated: entry.date + ) + Divider() + if let message = entry.message { + emptyState(message, symbol: "arrow.triangle.pull") + } else { + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(entry.rows.enumerated()), id: \.offset) { _, row in + VStack(alignment: .leading, spacing: 2) { + Text(row.title) + .font(.caption.weight(.semibold)) + .lineLimit(2) + Text(row.meta) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .accessibilityElement(children: .combine) + } + } + .privacySensitive() + } + } + .containerBackground(.background, for: .widget) + .widgetURL(URL(string: "gotcha://pulls")) + } +} + +private func emptyState(_ message: String, symbol: String) -> some View { + VStack(spacing: 8) { + Spacer() + Image(systemName: symbol) + .font(.title2) + .foregroundStyle(.secondary) + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + Spacer() + } + .frame(maxWidth: .infinity) +} + +struct ActivityWidget: Widget { + let kind = "GotchaActivityWidget" + + var body: some WidgetConfiguration { + AppIntentConfiguration(kind: kind, intent: ActivityWidgetIntent.self, provider: ActivityProvider()) { + ActivityWidgetView(entry: $0) + } + .configurationDisplayName("Recent Activity") + .description("See the latest activity from a configured Gitea or Forgejo server.") + .supportedFamilies([.systemLarge]) + } +} + +struct PullsWidget: Widget { + let kind = "GotchaPullsWidget" + + var body: some WidgetConfiguration { + AppIntentConfiguration(kind: kind, intent: PullsWidgetIntent.self, provider: PullsProvider()) { + PullsWidgetView(entry: $0) + } + .configurationDisplayName("Open Pull Requests") + .description("See open pull requests from a configured Gitea or Forgejo server.") + .supportedFamilies([.systemLarge]) + } +} + +@main +struct GotchaWidgetBundle: WidgetBundle { + var body: some Widget { + ActivityWidget() + PullsWidget() + } +} diff --git a/ios/project.yml b/ios/project.yml index f04e53f..8a657a1 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -34,6 +34,10 @@ targets: CFBundleShortVersionString: "$(MARKETING_VERSION)" CFBundleVersion: "$(CURRENT_PROJECT_VERSION)" ITSAppUsesNonExemptEncryption: false + CFBundleURLTypes: + - CFBundleURLName: de.rfc1437.gotcha + CFBundleURLSchemes: + - gotcha UILaunchScreen: {} UISupportedInterfaceOrientations: - UIInterfaceOrientationPortrait @@ -45,6 +49,42 @@ targets: dependencies: - package: Highlighter - package: MarkdownUI + - target: GotchaWidgets + embed: true + preBuildScripts: + - name: Build Rust core + basedOnDependencyAnalysis: false + script: | + ./build_rust_core.bash + outputFiles: + - $(DERIVED_FILE_DIR)/rust/libgotcha_core.a + GotchaWidgets: + type: app-extension + platform: iOS + deploymentTarget: "17.0" + settings: + PRODUCT_BUNDLE_IDENTIFIER: de.rfc1437.gotcha.widgets + DEVELOPMENT_TEAM: MU22FMRGK8 + CODE_SIGN_STYLE: Automatic + CODE_SIGN_ENTITLEMENTS: GotchaWidgets.entitlements + SWIFT_VERSION: "5.0" + SWIFT_OBJC_BRIDGING_HEADER: Gotcha-Bridging-Header.h + LIBRARY_SEARCH_PATHS: "$(inherited) $(DERIVED_FILE_DIR)/rust" + OTHER_LDFLAGS: "$(inherited) -lgotcha_core" + APPLICATION_EXTENSION_API_ONLY: YES + SKIP_INSTALL: YES + info: + path: GotchaWidgets-Info.plist + properties: + CFBundleDisplayName: Gotcha Widgets + ITSAppUsesNonExemptEncryption: false + NSExtension: + NSExtensionPointIdentifier: com.apple.widgetkit-extension + sources: + - Widgets + - Sources/WidgetIntents.swift + - PrivacyInfo.xcprivacy + - Generated/gotcha_core.swift preBuildScripts: - name: Build Rust core basedOnDependencyAnalysis: false