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

@@ -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

View File

@@ -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

View File

@@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.de.rfc1437.gotcha</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)$(CFBundleIdentifier)</string>

View File

@@ -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 = "<group>"; };
0BB99EB32ECA1F8C55293EE3 /* WorkItemDetailScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkItemDetailScreens.swift; sourceTree = "<group>"; };
17264C5C909501D8ADEC00B7 /* IssueActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueActions.swift; sourceTree = "<group>"; };
181AE294D07DB4EAC9C9A0FF /* RepositoryFileScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepositoryFileScreens.swift; sourceTree = "<group>"; };
41E0F726C9668B50418D9ADF /* DiffScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffScreen.swift; sourceTree = "<group>"; };
5121A6F14A9C9F6EA144BB2F /* GotchaWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GotchaWidgets.swift; sourceTree = "<group>"; };
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 = "<group>"; };
722405F999984C7CBE838711 /* IssueEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueEditorViewController.swift; sourceTree = "<group>"; };
7588A44A93B8DB4B78C3B2BB /* CommitScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitScreens.swift; sourceTree = "<group>"; };
77AE1D594C38D2BDAA2E86AD /* WidgetIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetIntents.swift; sourceTree = "<group>"; };
81A55AAC9DB99FEB79F56DAF /* CommitDetailScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommitDetailScreen.swift; sourceTree = "<group>"; };
8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = "<group>"; };
8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentEditorViewController.swift; sourceTree = "<group>"; };
@@ -74,6 +107,14 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
1E34F71186DBE1669D42B546 /* Widgets */ = {
isa = PBXGroup;
children = (
5121A6F14A9C9F6EA144BB2F /* GotchaWidgets.swift */,
);
path = Widgets;
sourceTree = "<group>";
};
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 = "<group>";
@@ -124,6 +167,7 @@
isa = PBXGroup;
children = (
5FB3250A93766966A60A685E /* Gotcha.app */,
52ABD1B07CEEF00263038653 /* GotchaWidgets.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -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 = (

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Gotcha Widgets</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.de.rfc1437.gotcha</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)$(CFBundleIdentifier)</string>
</array>
</dict>
</plist>

View File

@@ -18,6 +18,17 @@
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>de.rfc1437.gotcha</string>
<key>CFBundleURLSchemes</key>
<array>
<string>gotcha</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -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 {

View File

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

View File

@@ -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?
}

View File

@@ -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<ActivityEntry> {
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<PullsEntry> {
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()
}
}

View File

@@ -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