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