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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user