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:
+25
@@ -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/
|
||||||
@@ -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 */;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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") +
|
||||||
|
")"
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
Reference in New Issue
Block a user