Initial commit — iOS share extension for filing Gitea issues

Two-target Xcode project (xcodegen spec). The GiteaIssue container app
holds the base URL + personal access token in a shared keychain group;
the GiteaIssueShare extension reads them, surfaces a repo picker (with
recents) and a title/notes form, then creates the issue, uploads the
screenshot as an asset, and patches the body to embed it inline.

Min iOS 26.0, signing team V3PF3M6B6U.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-04-26 23:15:43 -05:00
commit 74e03c4e10
17 changed files with 1451 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
## macOS
.DS_Store
## Xcode
build/
*.xcuserstate
xcuserdata/
*.xcworkspace
DerivedData/
*.moved-aside
*.hmap
*.ipa
*.dSYM.zip
*.dSYM
## Generated by xcodegen — keep tracked so the builder doesn't need xcodegen installed
# (intentionally NOT ignoring *.xcodeproj)
## Swift Package Manager
.swiftpm/
.build/
Packages/
Package.pins
Package.resolved
*.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/
+487
View File
@@ -0,0 +1,487 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
01EE3AAB26736728CA93C686 /* ShareSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943C9E92238296254E79717B /* ShareSheetView.swift */; };
07EED00FE7DDDA0CAFBC35E3 /* GiteaIssueShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = C835A0DE1B14653FF82534D6 /* GiteaIssueShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
186BF400248B98EFD6B73FE4 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8783ACB69D59F2D49C512D5E /* Keychain.swift */; };
1BD5244ACCEAC64099EEBB4D /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88DA04623CDDD48C183CD674 /* Settings.swift */; };
289A0520E023B3DE765CAE54 /* GiteaClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FBDAD2C584C35B08A12E6C5 /* GiteaClient.swift */; };
35FC7E228E0B41F5D0D692B3 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E3E2D5BC7DE7A285F7AFB6 /* SettingsView.swift */; };
3764DB595E046C831517776B /* HelpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E60AB1F455BE4290BBB0A653 /* HelpView.swift */; };
5014022146E8A565C0FE4742 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8783ACB69D59F2D49C512D5E /* Keychain.swift */; };
5259B0BCE30064A20F5750C9 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F9B72394E791840385F7CA /* Models.swift */; };
58CB61A5A917AD7174F17E45 /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88DA04623CDDD48C183CD674 /* Settings.swift */; };
6CDAA55A2740CF46137C04F3 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6A36396FE48CA333CA1E4B7 /* ShareViewController.swift */; };
A956C3B78247D43E0BA4C6D5 /* RepoCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50238EB026C45E93757D6034 /* RepoCache.swift */; };
B3EE4A41233945C5FDDF138D /* GiteaIssueApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5302974F9B6C6614445906E /* GiteaIssueApp.swift */; };
BE4BF5491648BEFAB36609C3 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F9B72394E791840385F7CA /* Models.swift */; };
CB763FC7C88126BDC8C70675 /* GiteaClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FBDAD2C584C35B08A12E6C5 /* GiteaClient.swift */; };
DB1BA9B0DAFEE5F278C26869 /* RepoCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50238EB026C45E93757D6034 /* RepoCache.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
F5C239800D6DD31B3979E2A3 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 27251B64FF480AB8EC0A6E41 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 8530533F8ED42AE5A8DC73EB;
remoteInfo = GiteaIssueShare;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
437F35B7AC1E4C86A2B4B3AB /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
07EED00FE7DDDA0CAFBC35E3 /* GiteaIssueShare.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
282BE61DAAEDDE1523B95A12 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
50238EB026C45E93757D6034 /* RepoCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepoCache.swift; sourceTree = "<group>"; };
5B7B8092213B20CA751D33FE /* GiteaIssue.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GiteaIssue.entitlements; sourceTree = "<group>"; };
7FBDAD2C584C35B08A12E6C5 /* GiteaClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GiteaClient.swift; sourceTree = "<group>"; };
8783ACB69D59F2D49C512D5E /* Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = "<group>"; };
88DA04623CDDD48C183CD674 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = "<group>"; };
943C9E92238296254E79717B /* ShareSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheetView.swift; sourceTree = "<group>"; };
96E3E2D5BC7DE7A285F7AFB6 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
B5302974F9B6C6614445906E /* GiteaIssueApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GiteaIssueApp.swift; sourceTree = "<group>"; };
C4F9B72394E791840385F7CA /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
C8019B6DFDB9FFE978C1A8A1 /* GiteaIssueShare.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GiteaIssueShare.entitlements; sourceTree = "<group>"; };
C835A0DE1B14653FF82534D6 /* GiteaIssueShare.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = GiteaIssueShare.appex; sourceTree = BUILT_PRODUCTS_DIR; };
E60AB1F455BE4290BBB0A653 /* HelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelpView.swift; sourceTree = "<group>"; };
E6A36396FE48CA333CA1E4B7 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
F817F3CE31E9F23573E92E96 /* GiteaIssue.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = GiteaIssue.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
260438FC4CFC248774904FD2 = {
isa = PBXGroup;
children = (
99014873FB3E05D905AC0F72 /* GiteaIssue */,
943233C666B3E7A65EBA983B /* GiteaIssueShare */,
282DF4582F6764AE4B98418F /* Shared */,
5F0DE786E5E3BF399FAB1AA1 /* Products */,
);
sourceTree = "<group>";
};
282DF4582F6764AE4B98418F /* Shared */ = {
isa = PBXGroup;
children = (
7FBDAD2C584C35B08A12E6C5 /* GiteaClient.swift */,
8783ACB69D59F2D49C512D5E /* Keychain.swift */,
C4F9B72394E791840385F7CA /* Models.swift */,
50238EB026C45E93757D6034 /* RepoCache.swift */,
88DA04623CDDD48C183CD674 /* Settings.swift */,
);
path = Shared;
sourceTree = "<group>";
};
5F0DE786E5E3BF399FAB1AA1 /* Products */ = {
isa = PBXGroup;
children = (
F817F3CE31E9F23573E92E96 /* GiteaIssue.app */,
C835A0DE1B14653FF82534D6 /* GiteaIssueShare.appex */,
);
name = Products;
sourceTree = "<group>";
};
943233C666B3E7A65EBA983B /* GiteaIssueShare */ = {
isa = PBXGroup;
children = (
C8019B6DFDB9FFE978C1A8A1 /* GiteaIssueShare.entitlements */,
282BE61DAAEDDE1523B95A12 /* Info.plist */,
943C9E92238296254E79717B /* ShareSheetView.swift */,
E6A36396FE48CA333CA1E4B7 /* ShareViewController.swift */,
);
path = GiteaIssueShare;
sourceTree = "<group>";
};
99014873FB3E05D905AC0F72 /* GiteaIssue */ = {
isa = PBXGroup;
children = (
5B7B8092213B20CA751D33FE /* GiteaIssue.entitlements */,
B5302974F9B6C6614445906E /* GiteaIssueApp.swift */,
E60AB1F455BE4290BBB0A653 /* HelpView.swift */,
96E3E2D5BC7DE7A285F7AFB6 /* SettingsView.swift */,
);
path = GiteaIssue;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
8530533F8ED42AE5A8DC73EB /* GiteaIssueShare */ = {
isa = PBXNativeTarget;
buildConfigurationList = 5442EF69B0C92817306C8BB0 /* Build configuration list for PBXNativeTarget "GiteaIssueShare" */;
buildPhases = (
9B221782680ADACF44216436 /* Sources */,
);
buildRules = (
);
dependencies = (
);
name = GiteaIssueShare;
packageProductDependencies = (
);
productName = GiteaIssueShare;
productReference = C835A0DE1B14653FF82534D6 /* GiteaIssueShare.appex */;
productType = "com.apple.product-type.app-extension";
};
C0A6998D8057F3D0D81418CC /* GiteaIssue */ = {
isa = PBXNativeTarget;
buildConfigurationList = EBCA52B3DCF7CF96695F838F /* Build configuration list for PBXNativeTarget "GiteaIssue" */;
buildPhases = (
9E8A23BF1FC9CD9F5A31EF77 /* Sources */,
437F35B7AC1E4C86A2B4B3AB /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
BF1D933F53035433A27EFE31 /* PBXTargetDependency */,
);
name = GiteaIssue;
packageProductDependencies = (
);
productName = GiteaIssue;
productReference = F817F3CE31E9F23573E92E96 /* GiteaIssue.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
27251B64FF480AB8EC0A6E41 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1430;
TargetAttributes = {
8530533F8ED42AE5A8DC73EB = {
DevelopmentTeam = V3PF3M6B6U;
ProvisioningStyle = Automatic;
};
C0A6998D8057F3D0D81418CC = {
DevelopmentTeam = V3PF3M6B6U;
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = 7B049227F0861B8B31732B4B /* Build configuration list for PBXProject "GiteaIssue" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = 260438FC4CFC248774904FD2;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = 5F0DE786E5E3BF399FAB1AA1 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
C0A6998D8057F3D0D81418CC /* GiteaIssue */,
8530533F8ED42AE5A8DC73EB /* GiteaIssueShare */,
);
};
/* End PBXProject section */
/* Begin PBXSourcesBuildPhase section */
9B221782680ADACF44216436 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
289A0520E023B3DE765CAE54 /* GiteaClient.swift in Sources */,
186BF400248B98EFD6B73FE4 /* Keychain.swift in Sources */,
BE4BF5491648BEFAB36609C3 /* Models.swift in Sources */,
DB1BA9B0DAFEE5F278C26869 /* RepoCache.swift in Sources */,
58CB61A5A917AD7174F17E45 /* Settings.swift in Sources */,
01EE3AAB26736728CA93C686 /* ShareSheetView.swift in Sources */,
6CDAA55A2740CF46137C04F3 /* ShareViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
9E8A23BF1FC9CD9F5A31EF77 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
CB763FC7C88126BDC8C70675 /* GiteaClient.swift in Sources */,
B3EE4A41233945C5FDDF138D /* GiteaIssueApp.swift in Sources */,
3764DB595E046C831517776B /* HelpView.swift in Sources */,
5014022146E8A565C0FE4742 /* Keychain.swift in Sources */,
5259B0BCE30064A20F5750C9 /* Models.swift in Sources */,
A956C3B78247D43E0BA4C6D5 /* RepoCache.swift in Sources */,
1BD5244ACCEAC64099EEBB4D /* Settings.swift in Sources */,
35FC7E228E0B41F5D0D692B3 /* SettingsView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
BF1D933F53035433A27EFE31 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 8530533F8ED42AE5A8DC73EB /* GiteaIssueShare */;
targetProxy = F5C239800D6DD31B3979E2A3 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
1758188C5D1C2C0169FB4797 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = GiteaIssueShare/GiteaIssueShare.entitlements;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = GiteaIssueShare/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.treytartt.GiteaIssue.Share;
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
451ECB132FE921DA75BBA36D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = V3PF3M6B6U;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.10;
};
name = Release;
};
6F09C48D2C8D1468759D6FD8 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_ENTITLEMENTS = GiteaIssueShare/GiteaIssueShare.entitlements;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = GiteaIssueShare/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.treytartt.GiteaIssue.Share;
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
732A9163E55043084DB2FB4A /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = GiteaIssue/GiteaIssue.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
INFOPLIST_KEY_CFBundleDisplayName = "Gitea Issue";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.treytartt.GiteaIssue;
SDKROOT = iphoneos;
SUPPORTS_MACCATALYST = NO;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
976B108502C6E173A5101066 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = V3PF3M6B6U;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"DEBUG=1",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.0.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.10;
};
name = Debug;
};
D1D47330074D8F620569EEFB /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = GiteaIssue/GiteaIssue.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
INFOPLIST_KEY_CFBundleDisplayName = "Gitea Issue";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.treytartt.GiteaIssue;
SDKROOT = iphoneos;
SUPPORTS_MACCATALYST = NO;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
5442EF69B0C92817306C8BB0 /* Build configuration list for PBXNativeTarget "GiteaIssueShare" */ = {
isa = XCConfigurationList;
buildConfigurations = (
1758188C5D1C2C0169FB4797 /* Debug */,
6F09C48D2C8D1468759D6FD8 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
7B049227F0861B8B31732B4B /* Build configuration list for PBXProject "GiteaIssue" */ = {
isa = XCConfigurationList;
buildConfigurations = (
976B108502C6E173A5101066 /* Debug */,
451ECB132FE921DA75BBA36D /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
EBCA52B3DCF7CF96695F838F /* Build configuration list for PBXNativeTarget "GiteaIssue" */ = {
isa = XCConfigurationList;
buildConfigurations = (
732A9163E55043084DB2FB4A /* Debug */,
D1D47330074D8F620569EEFB /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
};
rootObject = 27251B64FF480AB8EC0A6E41 /* Project object */;
}
+14
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.com.treytartt.GiteaIssue</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.treytartt.GiteaIssue</string>
</array>
</dict>
</plist>
+21
View File
@@ -0,0 +1,21 @@
import SwiftUI
@main
struct GiteaIssueApp: App {
var body: some Scene {
WindowGroup {
RootView()
}
}
}
struct RootView: View {
var body: some View {
TabView {
SettingsView()
.tabItem { Label("Settings", systemImage: "gearshape") }
HelpView()
.tabItem { Label("How to use", systemImage: "questionmark.circle") }
}
}
}
+47
View File
@@ -0,0 +1,47 @@
import SwiftUI
struct HelpView: View {
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text("How to file an issue")
.font(.title2.bold())
step(number: 1, title: "Take a screenshot", body: "Side button + volume up.")
step(number: 2, title: "Open the screenshot", body: "Either tap the thumbnail right after capture, or open Photos.")
step(number: 3, title: "Tap Share", body: "The square-with-arrow icon at the bottom-left.")
step(number: 4, title: "Pick \"Gitea Issue\"", body: "Scroll the icon row and tap the Gitea Issue extension.")
step(number: 5, title: "Choose a repo, write a title", body: "Notes are optional. Tap Submit. The screenshot gets attached and embedded inline.")
Divider()
Text("First time?")
.font(.headline)
Text("Open the Settings tab and add your Gitea base URL and a personal access token (with `repo` scope). Tap Save & test connection — when it shows your login, you're set.")
.foregroundStyle(.secondary)
}
.padding()
}
.navigationTitle("Help")
}
}
@ViewBuilder
private func step(number: Int, title: String, body: String) -> some View {
HStack(alignment: .top, spacing: 12) {
ZStack {
Circle().fill(.tint).frame(width: 28, height: 28)
Text("\(number)").font(.callout.bold()).foregroundStyle(.white)
}
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.headline)
Text(body).foregroundStyle(.secondary).font(.subheadline)
}
}
}
}
#Preview {
HelpView()
}
+98
View File
@@ -0,0 +1,98 @@
import SwiftUI
struct SettingsView: View {
@State private var baseURL: String = AppSettings.baseURL
@State private var token: String = AppSettings.token
@State private var testResult: TestResult?
@State private var testing = false
enum TestResult: Equatable {
case success(String)
case failure(String)
}
var body: some View {
NavigationStack {
Form {
Section("Gitea") {
TextField("Base URL", text: $baseURL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
SecureField("Personal access token", text: $token)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
Section {
Button {
save()
runTest()
} label: {
HStack {
Text(testing ? "Testing…" : "Save & test connection")
Spacer()
if testing { ProgressView() }
}
}
.disabled(testing || baseURL.isEmpty || token.isEmpty)
if let result = testResult {
switch result {
case .success(let login):
Label("Connected as \(login)", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
case .failure(let msg):
Label(msg, systemImage: "xmark.octagon.fill")
.foregroundStyle(.red)
}
}
}
Section("Token scope") {
Text("The token needs `repo` scope so the share extension can create issues and upload attachments.")
.font(.footnote)
.foregroundStyle(.secondary)
Link("Open Gitea token settings", destination: tokenSettingsURL)
.font(.footnote)
}
}
.navigationTitle("Gitea Issue")
}
}
private var tokenSettingsURL: URL {
URL(string: baseURL)?.appendingPathComponent("/user/settings/applications") ?? URL(string: "https://gitea.treytartt.com/user/settings/applications")!
}
private func save() {
AppSettings.baseURL = baseURL.trimmingCharacters(in: .whitespacesAndNewlines)
AppSettings.token = token.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func runTest() {
testing = true
testResult = nil
Task {
do {
let client = try GiteaClient()
let user = try await client.currentUser()
let repos = try await client.searchRepos()
RepoCache.repos = repos
await MainActor.run {
testing = false
testResult = .success("\(user.login) — cached \(repos.count) repos")
}
} catch {
await MainActor.run {
testing = false
testResult = .failure(error.localizedDescription)
}
}
}
}
}
#Preview {
SettingsView()
}
@@ -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.com.treytartt.GiteaIssue</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)com.treytartt.GiteaIssue</string>
</array>
</dict>
</plist>
+39
View File
@@ -0,0 +1,39 @@
<?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>Gitea Issue</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>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
<integer>1</integer>
</dict>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
</dict>
</plist>
+227
View File
@@ -0,0 +1,227 @@
import SwiftUI
import UIKit
struct ShareSheetView: View {
let image: UIImage?
var imageData: Data? = nil
var errorMessage: String? = nil
let onClose: () -> Void
@State private var repos: [GiteaRepo] = RepoCache.repos
@State private var loadingRepos = false
@State private var loadError: String?
@State private var search = ""
@State private var selectedRepo: GiteaRepo?
@State private var title = ""
@State private var notes = ""
@State private var submitting = false
@State private var submitError: String?
@State private var submitted: GiteaIssue?
var body: some View {
NavigationStack {
Form {
if let error = errorMessage {
Section {
Label(error, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
}
}
if let image {
Section {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(maxHeight: 160)
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
Section("Repository") {
if loadingRepos && repos.isEmpty {
HStack { ProgressView(); Text("Loading repos…") }
} else if let loadError {
Label(loadError, systemImage: "xmark.octagon")
.foregroundStyle(.red)
} else {
repoPicker
}
}
Section("Issue") {
TextField("Title", text: $title)
.textInputAutocapitalization(.sentences)
TextField("Notes (optional)", text: $notes, axis: .vertical)
.lineLimit(3...8)
}
if let submitError {
Section {
Label(submitError, systemImage: "exclamationmark.triangle")
.foregroundStyle(.red)
}
}
if let issue = submitted {
Section {
Label("Created issue #\(issue.number)", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
Link("Open in Gitea", destination: URL(string: issue.htmlUrl)!)
}
}
}
.navigationTitle("New issue")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { onClose() }
}
ToolbarItem(placement: .confirmationAction) {
Button(submitting ? "Sending…" : "Submit") { submit() }
.disabled(!canSubmit)
}
}
.task { await loadReposIfNeeded() }
}
}
private var canSubmit: Bool {
selectedRepo != nil && !title.trimmingCharacters(in: .whitespaces).isEmpty && !submitting && submitted == nil
}
@ViewBuilder
private var repoPicker: some View {
TextField("Search repos", text: $search)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
let filtered = filteredRepos
let recents = recentRepos.filter { search.isEmpty || $0.fullName.localizedCaseInsensitiveContains(search) }
if !recents.isEmpty {
ForEach(recents) { repo in
row(repo, badge: "Recent")
}
}
ForEach(filtered.filter { r in !recents.contains(where: { $0.id == r.id }) }) { repo in
row(repo, badge: nil)
}
Button {
Task { await refreshRepos(force: true) }
} label: {
Label("Refresh repo list", systemImage: "arrow.clockwise")
}
}
private func row(_ repo: GiteaRepo, badge: String?) -> some View {
Button {
selectedRepo = repo
} label: {
HStack {
VStack(alignment: .leading) {
Text(repo.fullName)
.foregroundStyle(.primary)
if let badge {
Text(badge).font(.caption2).foregroundStyle(.secondary)
}
}
Spacer()
if selectedRepo?.id == repo.id {
Image(systemName: "checkmark").foregroundStyle(.tint)
}
}
}
}
private var filteredRepos: [GiteaRepo] {
let sorted = repos.sorted { $0.fullName.localizedCaseInsensitiveCompare($1.fullName) == .orderedAscending }
guard !search.isEmpty else { return sorted }
return sorted.filter { $0.fullName.localizedCaseInsensitiveContains(search) }
}
private var recentRepos: [GiteaRepo] {
let names = RepoCache.recentFullNames
return names.compactMap { name in repos.first(where: { $0.fullName == name }) }
}
private func loadReposIfNeeded() async {
if repos.isEmpty || RepoCache.isStale {
await refreshRepos(force: false)
}
}
private func refreshRepos(force: Bool) async {
loadingRepos = true
loadError = nil
do {
let client = try GiteaClient()
let fresh = try await client.searchRepos()
await MainActor.run {
self.repos = fresh
RepoCache.repos = fresh
self.loadingRepos = false
}
} catch {
await MainActor.run {
self.loadError = error.localizedDescription
self.loadingRepos = false
}
}
}
private func submit() {
guard let repo = selectedRepo, let imageData else {
submitError = "Pick a repo and make sure the screenshot loaded."
return
}
submitting = true
submitError = nil
Task {
do {
let client = try GiteaClient()
let bodyText = notes.trimmingCharacters(in: .whitespacesAndNewlines)
let issue = try await client.createIssue(
owner: repo.owner.login,
repo: repo.name,
title: title.trimmingCharacters(in: .whitespacesAndNewlines),
body: bodyText
)
let asset = try await client.uploadAsset(
owner: repo.owner.login,
repo: repo.name,
issueNumber: issue.number,
image: imageData,
filename: "screenshot-\(Int(Date().timeIntervalSince1970)).png"
)
let updatedBody = (bodyText.isEmpty ? "" : bodyText + "\n\n") +
"![screenshot](\(asset.browserDownloadUrl))"
try await client.updateIssueBody(
owner: repo.owner.login,
repo: repo.name,
issueNumber: issue.number,
body: updatedBody
)
RepoCache.touch(repo.fullName)
await MainActor.run {
submitting = false
submitted = issue
UINotificationFeedbackGenerator().notificationOccurred(.success)
}
try? await Task.sleep(nanoseconds: 800_000_000)
await MainActor.run { onClose() }
} catch {
await MainActor.run {
submitting = false
submitError = error.localizedDescription
UINotificationFeedbackGenerator().notificationOccurred(.error)
}
}
}
}
}
+67
View File
@@ -0,0 +1,67 @@
import UIKit
import SwiftUI
import UniformTypeIdentifiers
final class ShareViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
Task { await loadAttachment() }
}
private func loadAttachment() async {
guard let item = (extensionContext?.inputItems as? [NSExtensionItem])?.first,
let provider = item.attachments?.first(where: { $0.hasItemConformingToTypeIdentifier(UTType.image.identifier) })
else {
present(host: ShareSheetView(image: nil, onClose: { [weak self] in self?.complete() }))
return
}
do {
let data = try await loadImageData(from: provider)
await MainActor.run {
let image = data.flatMap { UIImage(data: $0) }
let host = ShareSheetView(
image: image,
imageData: data,
onClose: { [weak self] in self?.complete() }
)
self.present(host: host)
}
} catch {
await MainActor.run {
present(host: ShareSheetView(image: nil, errorMessage: error.localizedDescription, onClose: { [weak self] in self?.complete() }))
}
}
}
private func loadImageData(from provider: NSItemProvider) async throws -> Data? {
try await withCheckedThrowingContinuation { continuation in
provider.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { item, error in
if let error { continuation.resume(throwing: error); return }
if let data = item as? Data { continuation.resume(returning: data); return }
if let url = item as? URL, let data = try? Data(contentsOf: url) { continuation.resume(returning: data); return }
if let image = item as? UIImage, let data = image.pngData() { continuation.resume(returning: data); return }
continuation.resume(returning: nil)
}
}
}
private func present(host: ShareSheetView) {
let controller = UIHostingController(rootView: host)
controller.modalPresentationStyle = .formSheet
addChild(controller)
controller.view.frame = view.bounds
controller.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
view.addSubview(controller.view)
controller.didMove(toParent: self)
}
fileprivate func complete() {
extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
}
fileprivate func cancel() {
extensionContext?.cancelRequest(withError: NSError(domain: "GiteaIssueShare", code: 0))
}
}
+46
View File
@@ -0,0 +1,46 @@
# Gitea Issue
iOS share-extension for filing issues to a self-hosted Gitea instance straight from a screenshot.
Take screenshot → Share → **Gitea Issue** → pick repo, title, notes → submit. The screenshot is uploaded as an attachment and embedded inline in the issue body.
## Architecture
Two targets in one Xcode project, sharing an App Group + Keychain:
- **GiteaIssue** (`com.treytartt.GiteaIssue`) — container app, settings + help only
- **GiteaIssueShare** (`com.treytartt.GiteaIssue.Share`) — share extension that does the real work
- **App Group**: `group.com.treytartt.GiteaIssue` (cached repo list, recents)
- **Keychain group**: `$(AppIdentifierPrefix)com.treytartt.GiteaIssue` (base URL, token)
iOS 26.0 minimum. Signing team `V3PF3M6B6U`.
## Layout
```
GiteaIssue/ Container app (settings + help)
GiteaIssueShare/ Share extension UI
Shared/ Models, Keychain, Settings, GiteaClient, RepoCache
project.yml xcodegen spec — regenerate with `xcodegen generate`
```
## Build
```bash
xcodegen generate
xcodebuild -project GiteaIssue.xcodeproj -scheme GiteaIssue \
-destination 'generic/platform=iOS' build
```
## Deploy (ad-hoc)
Push to `admin/GiteaIssue` on Gitea, then trigger the Mac mini builder at
`http://Treys-Mac-mini.local:3090` against the git URL. Builder ships the IPA
to `https://appstore.treytartt.com` for OTA install.
## First-time setup on device
1. Install via Safari from `appstore.treytartt.com`
2. Open the Gitea Issue app, set base URL + personal access token (`repo` scope)
3. Tap **Save & test connection** — confirms login and pre-fetches the repo list
4. From now on: Photos → screenshot → Share → Gitea Issue
+99
View File
@@ -0,0 +1,99 @@
import Foundation
struct GiteaClient {
let baseURL: URL
let token: String
init() throws {
guard AppSettings.isConfigured else { throw GiteaError.notConfigured }
guard let url = URL(string: AppSettings.baseURL) else { throw GiteaError.invalidBaseURL }
self.baseURL = url
self.token = AppSettings.token
}
func currentUser() async throws -> GiteaUser {
let req = makeRequest(path: "/api/v1/user")
let (data, resp) = try await URLSession.shared.data(for: req)
try ensureOK(resp, data: data)
return try decode(data)
}
func searchRepos() async throws -> [GiteaRepo] {
var components = URLComponents(url: baseURL.appendingPathComponent("/api/v1/repos/search"), resolvingAgainstBaseURL: false)!
components.queryItems = [
URLQueryItem(name: "limit", value: "50"),
URLQueryItem(name: "sort", value: "updated"),
URLQueryItem(name: "order", value: "desc"),
]
var req = URLRequest(url: components.url!)
req.setValue("token \(token)", forHTTPHeaderField: "Authorization")
let (data, resp) = try await URLSession.shared.data(for: req)
try ensureOK(resp, data: data)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let result = try decoder.decode(GiteaRepoSearch.self, from: data)
return result.data
}
func createIssue(owner: String, repo: String, title: String, body: String) async throws -> GiteaIssue {
let payload = ["title": title, "body": body]
var req = makeRequest(path: "/api/v1/repos/\(owner)/\(repo)/issues", method: "POST")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONSerialization.data(withJSONObject: payload)
let (data, resp) = try await URLSession.shared.data(for: req)
try ensureOK(resp, data: data)
return try decode(data)
}
func uploadAsset(owner: String, repo: String, issueNumber: Int, image: Data, filename: String) async throws -> GiteaIssueAsset {
let boundary = "Boundary-\(UUID().uuidString)"
var req = makeRequest(path: "/api/v1/repos/\(owner)/\(repo)/issues/\(issueNumber)/assets", method: "POST")
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
var body = Data()
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"attachment\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
body.append("Content-Type: image/png\r\n\r\n".data(using: .utf8)!)
body.append(image)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
req.httpBody = body
let (data, resp) = try await URLSession.shared.data(for: req)
try ensureOK(resp, data: data)
return try decode(data)
}
func updateIssueBody(owner: String, repo: String, issueNumber: Int, body: String) async throws {
let payload = ["body": body]
var req = makeRequest(path: "/api/v1/repos/\(owner)/\(repo)/issues/\(issueNumber)", method: "PATCH")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONSerialization.data(withJSONObject: payload)
let (data, resp) = try await URLSession.shared.data(for: req)
try ensureOK(resp, data: data)
}
private func makeRequest(path: String, method: String = "GET") -> URLRequest {
var req = URLRequest(url: baseURL.appendingPathComponent(path))
req.httpMethod = method
req.setValue("token \(token)", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Accept")
return req
}
private func ensureOK(_ resp: URLResponse, data: Data) throws {
guard let http = resp as? HTTPURLResponse else { return }
if (200..<300).contains(http.statusCode) { return }
let body = String(data: data, encoding: .utf8) ?? ""
throw GiteaError.http(http.statusCode, body)
}
private func decode<T: Decodable>(_ data: Data) throws -> T {
do {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(T.self, from: data)
} catch {
throw GiteaError.decoding(String(describing: error))
}
}
}
+77
View File
@@ -0,0 +1,77 @@
import Foundation
import Security
enum Keychain {
static let accessGroup = "$(AppIdentifierPrefix)com.treytartt.GiteaIssue"
static let service = "com.treytartt.GiteaIssue"
static func read(_ key: String) -> String? {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnData as String: true,
]
if let group = resolvedAccessGroup() { query[kSecAttrAccessGroup as String] = group }
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess, let data = item as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
static func write(_ key: String, value: String) {
let data = Data(value.utf8)
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
if let group = resolvedAccessGroup() { query[kSecAttrAccessGroup as String] = group }
SecItemDelete(query as CFDictionary)
var add = query
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
SecItemAdd(add as CFDictionary, nil)
}
static func delete(_ key: String) {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
if let group = resolvedAccessGroup() { query[kSecAttrAccessGroup as String] = group }
SecItemDelete(query as CFDictionary)
}
private static func resolvedAccessGroup() -> String? {
guard let prefix = appIdentifierPrefix() else { return nil }
return "\(prefix)com.treytartt.GiteaIssue"
}
private static func appIdentifierPrefix() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "__appIdentifierPrefixProbe",
kSecAttrService as String: service,
kSecReturnAttributes as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess,
let attrs = result as? [String: Any],
let group = attrs[kSecAttrAccessGroup as String] as? String,
let dot = group.firstIndex(of: ".") {
return String(group[..<group.index(after: dot)])
}
if let bundleSeed = Bundle.main.object(forInfoDictionaryKey: "AppIdentifierPrefix") as? String {
return bundleSeed
}
return nil
}
}
+66
View File
@@ -0,0 +1,66 @@
import Foundation
struct GiteaUser: Decodable, Sendable {
let login: String
let id: Int
}
struct GiteaRepo: Codable, Identifiable, Hashable, Sendable {
let id: Int
let fullName: String
let name: String
let owner: Owner
let updatedAt: Date
struct Owner: Codable, Hashable, Sendable {
let login: String
}
enum CodingKeys: String, CodingKey {
case id, name, owner
case fullName = "full_name"
case updatedAt = "updated_at"
}
}
struct GiteaRepoSearch: Decodable, Sendable {
let data: [GiteaRepo]
}
struct GiteaIssue: Decodable, Sendable {
let id: Int
let number: Int
let htmlUrl: String
enum CodingKeys: String, CodingKey {
case id, number
case htmlUrl = "html_url"
}
}
struct GiteaIssueAsset: Decodable, Sendable {
let id: Int
let name: String
let browserDownloadUrl: String
enum CodingKeys: String, CodingKey {
case id, name
case browserDownloadUrl = "browser_download_url"
}
}
enum GiteaError: LocalizedError {
case notConfigured
case invalidBaseURL
case http(Int, String)
case decoding(String)
var errorDescription: String? {
switch self {
case .notConfigured: "Open the Gitea Issue app and set your base URL + token."
case .invalidBaseURL: "Base URL is not a valid URL."
case .http(let code, let body): "Gitea returned \(code). \(body)"
case .decoding(let msg): "Couldn't read response: \(msg)"
}
}
}
+46
View File
@@ -0,0 +1,46 @@
import Foundation
enum RepoCache {
private static let listKey = "repo.list"
private static let listFetchedKey = "repo.list.fetchedAt"
private static let recentsKey = "repo.recents"
private static let recentsCap = 5
static let staleAfter: TimeInterval = 24 * 60 * 60
static var repos: [GiteaRepo] {
get {
guard let data = AppSettings.sharedDefaults.data(forKey: listKey) else { return [] }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return (try? decoder.decode([GiteaRepo].self, from: data)) ?? []
}
set {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
if let data = try? encoder.encode(newValue) {
AppSettings.sharedDefaults.set(data, forKey: listKey)
AppSettings.sharedDefaults.set(Date(), forKey: listFetchedKey)
}
}
}
static var fetchedAt: Date? {
AppSettings.sharedDefaults.object(forKey: listFetchedKey) as? Date
}
static var isStale: Bool {
guard let fetched = fetchedAt else { return true }
return Date().timeIntervalSince(fetched) > staleAfter
}
static var recentFullNames: [String] {
AppSettings.sharedDefaults.stringArray(forKey: recentsKey) ?? []
}
static func touch(_ fullName: String) {
var current = recentFullNames.filter { $0 != fullName }
current.insert(fullName, at: 0)
if current.count > recentsCap { current = Array(current.prefix(recentsCap)) }
AppSettings.sharedDefaults.set(current, forKey: recentsKey)
}
}
+26
View File
@@ -0,0 +1,26 @@
import Foundation
enum AppSettings {
static let appGroup = "group.com.treytartt.GiteaIssue"
private static let baseURLKey = "gitea.baseURL"
private static let tokenKey = "gitea.token"
static var baseURL: String {
get { Keychain.read(baseURLKey) ?? "https://gitea.treytartt.com" }
set { Keychain.write(baseURLKey, value: newValue) }
}
static var token: String {
get { Keychain.read(tokenKey) ?? "" }
set { Keychain.write(tokenKey, value: newValue) }
}
static var isConfigured: Bool {
!token.isEmpty && URL(string: baseURL) != nil
}
static var sharedDefaults: UserDefaults {
UserDefaults(suiteName: appGroup) ?? .standard
}
}
+52
View File
@@ -0,0 +1,52 @@
name: GiteaIssue
options:
bundleIdPrefix: com.treytartt
deploymentTarget:
iOS: "26.0"
developmentLanguage: en
createIntermediateGroups: true
settings:
base:
DEVELOPMENT_TEAM: V3PF3M6B6U
SWIFT_VERSION: "5.10"
MARKETING_VERSION: "1.0.0"
CURRENT_PROJECT_VERSION: "1"
ENABLE_USER_SCRIPT_SANDBOXING: NO
SWIFT_EMIT_LOC_STRINGS: YES
GENERATE_INFOPLIST_FILE: YES
CODE_SIGN_STYLE: Automatic
targets:
GiteaIssue:
type: application
platform: iOS
sources:
- path: GiteaIssue
- path: Shared
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.treytartt.GiteaIssue
INFOPLIST_KEY_UILaunchScreen_Generation: YES
INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"
INFOPLIST_KEY_CFBundleDisplayName: "Gitea Issue"
TARGETED_DEVICE_FAMILY: "1,2"
SUPPORTS_MACCATALYST: NO
CODE_SIGN_ENTITLEMENTS: GiteaIssue/GiteaIssue.entitlements
dependencies:
- target: GiteaIssueShare
GiteaIssueShare:
type: app-extension
platform: iOS
sources:
- path: GiteaIssueShare
- path: Shared
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.treytartt.GiteaIssue.Share
INFOPLIST_FILE: GiteaIssueShare/Info.plist
GENERATE_INFOPLIST_FILE: NO
CODE_SIGN_ENTITLEMENTS: GiteaIssueShare/GiteaIssueShare.entitlements
TARGETED_DEVICE_FAMILY: "1,2"
SKIP_INSTALL: YES