commit 74e03c4e10493bbbaccd030d39b24ea3f8521afb Author: Claude Date: Sun Apr 26 23:15:43 2026 -0500 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f095c7 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/GiteaIssue.xcodeproj/project.pbxproj b/GiteaIssue.xcodeproj/project.pbxproj new file mode 100644 index 0000000..bac7d0d --- /dev/null +++ b/GiteaIssue.xcodeproj/project.pbxproj @@ -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 = ""; }; + 50238EB026C45E93757D6034 /* RepoCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepoCache.swift; sourceTree = ""; }; + 5B7B8092213B20CA751D33FE /* GiteaIssue.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GiteaIssue.entitlements; sourceTree = ""; }; + 7FBDAD2C584C35B08A12E6C5 /* GiteaClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GiteaClient.swift; sourceTree = ""; }; + 8783ACB69D59F2D49C512D5E /* Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = ""; }; + 88DA04623CDDD48C183CD674 /* Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Settings.swift; sourceTree = ""; }; + 943C9E92238296254E79717B /* ShareSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheetView.swift; sourceTree = ""; }; + 96E3E2D5BC7DE7A285F7AFB6 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + B5302974F9B6C6614445906E /* GiteaIssueApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GiteaIssueApp.swift; sourceTree = ""; }; + C4F9B72394E791840385F7CA /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + C8019B6DFDB9FFE978C1A8A1 /* GiteaIssueShare.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GiteaIssueShare.entitlements; sourceTree = ""; }; + 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 = ""; }; + E6A36396FE48CA333CA1E4B7 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; + 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 = ""; + }; + 282DF4582F6764AE4B98418F /* Shared */ = { + isa = PBXGroup; + children = ( + 7FBDAD2C584C35B08A12E6C5 /* GiteaClient.swift */, + 8783ACB69D59F2D49C512D5E /* Keychain.swift */, + C4F9B72394E791840385F7CA /* Models.swift */, + 50238EB026C45E93757D6034 /* RepoCache.swift */, + 88DA04623CDDD48C183CD674 /* Settings.swift */, + ); + path = Shared; + sourceTree = ""; + }; + 5F0DE786E5E3BF399FAB1AA1 /* Products */ = { + isa = PBXGroup; + children = ( + F817F3CE31E9F23573E92E96 /* GiteaIssue.app */, + C835A0DE1B14653FF82534D6 /* GiteaIssueShare.appex */, + ); + name = Products; + sourceTree = ""; + }; + 943233C666B3E7A65EBA983B /* GiteaIssueShare */ = { + isa = PBXGroup; + children = ( + C8019B6DFDB9FFE978C1A8A1 /* GiteaIssueShare.entitlements */, + 282BE61DAAEDDE1523B95A12 /* Info.plist */, + 943C9E92238296254E79717B /* ShareSheetView.swift */, + E6A36396FE48CA333CA1E4B7 /* ShareViewController.swift */, + ); + path = GiteaIssueShare; + sourceTree = ""; + }; + 99014873FB3E05D905AC0F72 /* GiteaIssue */ = { + isa = PBXGroup; + children = ( + 5B7B8092213B20CA751D33FE /* GiteaIssue.entitlements */, + B5302974F9B6C6614445906E /* GiteaIssueApp.swift */, + E60AB1F455BE4290BBB0A653 /* HelpView.swift */, + 96E3E2D5BC7DE7A285F7AFB6 /* SettingsView.swift */, + ); + path = GiteaIssue; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/GiteaIssue/GiteaIssue.entitlements b/GiteaIssue/GiteaIssue.entitlements new file mode 100644 index 0000000..905d4f9 --- /dev/null +++ b/GiteaIssue/GiteaIssue.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + group.com.treytartt.GiteaIssue + + keychain-access-groups + + $(AppIdentifierPrefix)com.treytartt.GiteaIssue + + + diff --git a/GiteaIssue/GiteaIssueApp.swift b/GiteaIssue/GiteaIssueApp.swift new file mode 100644 index 0000000..a8b30b7 --- /dev/null +++ b/GiteaIssue/GiteaIssueApp.swift @@ -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") } + } + } +} diff --git a/GiteaIssue/HelpView.swift b/GiteaIssue/HelpView.swift new file mode 100644 index 0000000..31e95a0 --- /dev/null +++ b/GiteaIssue/HelpView.swift @@ -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() +} diff --git a/GiteaIssue/SettingsView.swift b/GiteaIssue/SettingsView.swift new file mode 100644 index 0000000..9453d5e --- /dev/null +++ b/GiteaIssue/SettingsView.swift @@ -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() +} diff --git a/GiteaIssueShare/GiteaIssueShare.entitlements b/GiteaIssueShare/GiteaIssueShare.entitlements new file mode 100644 index 0000000..905d4f9 --- /dev/null +++ b/GiteaIssueShare/GiteaIssueShare.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + group.com.treytartt.GiteaIssue + + keychain-access-groups + + $(AppIdentifierPrefix)com.treytartt.GiteaIssue + + + diff --git a/GiteaIssueShare/Info.plist b/GiteaIssueShare/Info.plist new file mode 100644 index 0000000..32d8a56 --- /dev/null +++ b/GiteaIssueShare/Info.plist @@ -0,0 +1,39 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Gitea Issue + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationSupportsImageWithMaxCount + 1 + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController + + + diff --git a/GiteaIssueShare/ShareSheetView.swift b/GiteaIssueShare/ShareSheetView.swift new file mode 100644 index 0000000..9316ed2 --- /dev/null +++ b/GiteaIssueShare/ShareSheetView.swift @@ -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) + } + } + } + } +} diff --git a/GiteaIssueShare/ShareViewController.swift b/GiteaIssueShare/ShareViewController.swift new file mode 100644 index 0000000..4d4742a --- /dev/null +++ b/GiteaIssueShare/ShareViewController.swift @@ -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)) + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..98ca375 --- /dev/null +++ b/README.md @@ -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 diff --git a/Shared/GiteaClient.swift b/Shared/GiteaClient.swift new file mode 100644 index 0000000..4c5e5de --- /dev/null +++ b/Shared/GiteaClient.swift @@ -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(_ 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)) + } + } +} diff --git a/Shared/Keychain.swift b/Shared/Keychain.swift new file mode 100644 index 0000000..7dbe04e --- /dev/null +++ b/Shared/Keychain.swift @@ -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[.. 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) + } +} diff --git a/Shared/Settings.swift b/Shared/Settings.swift new file mode 100644 index 0000000..57f08b0 --- /dev/null +++ b/Shared/Settings.swift @@ -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 + } +} diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..7f85c81 --- /dev/null +++ b/project.yml @@ -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