Initial commit — iOS share extension for filing Gitea issues

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

Min iOS 26.0, signing team V3PF3M6B6U.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-04-26 23:15:43 -05:00
commit 74e03c4e10
17 changed files with 1451 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
import Foundation
struct GiteaClient {
let baseURL: URL
let token: String
init() throws {
guard AppSettings.isConfigured else { throw GiteaError.notConfigured }
guard let url = URL(string: AppSettings.baseURL) else { throw GiteaError.invalidBaseURL }
self.baseURL = url
self.token = AppSettings.token
}
func currentUser() async throws -> GiteaUser {
let req = makeRequest(path: "/api/v1/user")
let (data, resp) = try await URLSession.shared.data(for: req)
try ensureOK(resp, data: data)
return try decode(data)
}
func searchRepos() async throws -> [GiteaRepo] {
var components = URLComponents(url: baseURL.appendingPathComponent("/api/v1/repos/search"), resolvingAgainstBaseURL: false)!
components.queryItems = [
URLQueryItem(name: "limit", value: "50"),
URLQueryItem(name: "sort", value: "updated"),
URLQueryItem(name: "order", value: "desc"),
]
var req = URLRequest(url: components.url!)
req.setValue("token \(token)", forHTTPHeaderField: "Authorization")
let (data, resp) = try await URLSession.shared.data(for: req)
try ensureOK(resp, data: data)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let result = try decoder.decode(GiteaRepoSearch.self, from: data)
return result.data
}
func createIssue(owner: String, repo: String, title: String, body: String) async throws -> GiteaIssue {
let payload = ["title": title, "body": body]
var req = makeRequest(path: "/api/v1/repos/\(owner)/\(repo)/issues", method: "POST")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONSerialization.data(withJSONObject: payload)
let (data, resp) = try await URLSession.shared.data(for: req)
try ensureOK(resp, data: data)
return try decode(data)
}
func uploadAsset(owner: String, repo: String, issueNumber: Int, image: Data, filename: String) async throws -> GiteaIssueAsset {
let boundary = "Boundary-\(UUID().uuidString)"
var req = makeRequest(path: "/api/v1/repos/\(owner)/\(repo)/issues/\(issueNumber)/assets", method: "POST")
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
var body = Data()
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"attachment\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
body.append("Content-Type: image/png\r\n\r\n".data(using: .utf8)!)
body.append(image)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
req.httpBody = body
let (data, resp) = try await URLSession.shared.data(for: req)
try ensureOK(resp, data: data)
return try decode(data)
}
func updateIssueBody(owner: String, repo: String, issueNumber: Int, body: String) async throws {
let payload = ["body": body]
var req = makeRequest(path: "/api/v1/repos/\(owner)/\(repo)/issues/\(issueNumber)", method: "PATCH")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONSerialization.data(withJSONObject: payload)
let (data, resp) = try await URLSession.shared.data(for: req)
try ensureOK(resp, data: data)
}
private func makeRequest(path: String, method: String = "GET") -> URLRequest {
var req = URLRequest(url: baseURL.appendingPathComponent(path))
req.httpMethod = method
req.setValue("token \(token)", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Accept")
return req
}
private func ensureOK(_ resp: URLResponse, data: Data) throws {
guard let http = resp as? HTTPURLResponse else { return }
if (200..<300).contains(http.statusCode) { return }
let body = String(data: data, encoding: .utf8) ?? ""
throw GiteaError.http(http.statusCode, body)
}
private func decode<T: Decodable>(_ data: Data) throws -> T {
do {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return try decoder.decode(T.self, from: data)
} catch {
throw GiteaError.decoding(String(describing: error))
}
}
}
+77
View File
@@ -0,0 +1,77 @@
import Foundation
import Security
enum Keychain {
static let accessGroup = "$(AppIdentifierPrefix)com.treytartt.GiteaIssue"
static let service = "com.treytartt.GiteaIssue"
static func read(_ key: String) -> String? {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecMatchLimit as String: kSecMatchLimitOne,
kSecReturnData as String: true,
]
if let group = resolvedAccessGroup() { query[kSecAttrAccessGroup as String] = group }
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess, let data = item as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
static func write(_ key: String, value: String) {
let data = Data(value.utf8)
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
if let group = resolvedAccessGroup() { query[kSecAttrAccessGroup as String] = group }
SecItemDelete(query as CFDictionary)
var add = query
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
SecItemAdd(add as CFDictionary, nil)
}
static func delete(_ key: String) {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
if let group = resolvedAccessGroup() { query[kSecAttrAccessGroup as String] = group }
SecItemDelete(query as CFDictionary)
}
private static func resolvedAccessGroup() -> String? {
guard let prefix = appIdentifierPrefix() else { return nil }
return "\(prefix)com.treytartt.GiteaIssue"
}
private static func appIdentifierPrefix() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: "__appIdentifierPrefixProbe",
kSecAttrService as String: service,
kSecReturnAttributes as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess,
let attrs = result as? [String: Any],
let group = attrs[kSecAttrAccessGroup as String] as? String,
let dot = group.firstIndex(of: ".") {
return String(group[..<group.index(after: dot)])
}
if let bundleSeed = Bundle.main.object(forInfoDictionaryKey: "AppIdentifierPrefix") as? String {
return bundleSeed
}
return nil
}
}
+66
View File
@@ -0,0 +1,66 @@
import Foundation
struct GiteaUser: Decodable, Sendable {
let login: String
let id: Int
}
struct GiteaRepo: Codable, Identifiable, Hashable, Sendable {
let id: Int
let fullName: String
let name: String
let owner: Owner
let updatedAt: Date
struct Owner: Codable, Hashable, Sendable {
let login: String
}
enum CodingKeys: String, CodingKey {
case id, name, owner
case fullName = "full_name"
case updatedAt = "updated_at"
}
}
struct GiteaRepoSearch: Decodable, Sendable {
let data: [GiteaRepo]
}
struct GiteaIssue: Decodable, Sendable {
let id: Int
let number: Int
let htmlUrl: String
enum CodingKeys: String, CodingKey {
case id, number
case htmlUrl = "html_url"
}
}
struct GiteaIssueAsset: Decodable, Sendable {
let id: Int
let name: String
let browserDownloadUrl: String
enum CodingKeys: String, CodingKey {
case id, name
case browserDownloadUrl = "browser_download_url"
}
}
enum GiteaError: LocalizedError {
case notConfigured
case invalidBaseURL
case http(Int, String)
case decoding(String)
var errorDescription: String? {
switch self {
case .notConfigured: "Open the Gitea Issue app and set your base URL + token."
case .invalidBaseURL: "Base URL is not a valid URL."
case .http(let code, let body): "Gitea returned \(code). \(body)"
case .decoding(let msg): "Couldn't read response: \(msg)"
}
}
}
+46
View File
@@ -0,0 +1,46 @@
import Foundation
enum RepoCache {
private static let listKey = "repo.list"
private static let listFetchedKey = "repo.list.fetchedAt"
private static let recentsKey = "repo.recents"
private static let recentsCap = 5
static let staleAfter: TimeInterval = 24 * 60 * 60
static var repos: [GiteaRepo] {
get {
guard let data = AppSettings.sharedDefaults.data(forKey: listKey) else { return [] }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return (try? decoder.decode([GiteaRepo].self, from: data)) ?? []
}
set {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
if let data = try? encoder.encode(newValue) {
AppSettings.sharedDefaults.set(data, forKey: listKey)
AppSettings.sharedDefaults.set(Date(), forKey: listFetchedKey)
}
}
}
static var fetchedAt: Date? {
AppSettings.sharedDefaults.object(forKey: listFetchedKey) as? Date
}
static var isStale: Bool {
guard let fetched = fetchedAt else { return true }
return Date().timeIntervalSince(fetched) > staleAfter
}
static var recentFullNames: [String] {
AppSettings.sharedDefaults.stringArray(forKey: recentsKey) ?? []
}
static func touch(_ fullName: String) {
var current = recentFullNames.filter { $0 != fullName }
current.insert(fullName, at: 0)
if current.count > recentsCap { current = Array(current.prefix(recentsCap)) }
AppSettings.sharedDefaults.set(current, forKey: recentsKey)
}
}
+26
View File
@@ -0,0 +1,26 @@
import Foundation
enum AppSettings {
static let appGroup = "group.com.treytartt.GiteaIssue"
private static let baseURLKey = "gitea.baseURL"
private static let tokenKey = "gitea.token"
static var baseURL: String {
get { Keychain.read(baseURLKey) ?? "https://gitea.treytartt.com" }
set { Keychain.write(baseURLKey, value: newValue) }
}
static var token: String {
get { Keychain.read(tokenKey) ?? "" }
set { Keychain.write(tokenKey, value: newValue) }
}
static var isConfigured: Bool {
!token.isEmpty && URL(string: baseURL) != nil
}
static var sharedDefaults: UserDefaults {
UserDefaults(suiteName: appGroup) ?? .standard
}
}