Add in-app diagnostic logging + resilient inbox; bump to 1.2
Build iOS App / Build .ipa and publish source (push) Failing after 5m44s
Build iOS App / Build .ipa and publish source (push) Failing after 5m44s
- LogStore: bounded in-memory + persisted log of API calls and errors, viewable and shareable from Settings → View logs. - GiteaClient: log every request/response status (esp. 404s). - Inbox: a single failing repo no longer wipes the whole inbox — per-repo errors are caught, logged, and the rest still load. - Bump to 1.2 so SideStore offers the update.
This commit is contained in:
@@ -187,7 +187,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "GiteaPR";
|
||||
@@ -197,7 +197,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1;
|
||||
MARKETING_VERSION = 1.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.bueso.giteapr;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -211,7 +211,7 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 3;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "GiteaPR";
|
||||
@@ -221,7 +221,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.1;
|
||||
MARKETING_VERSION = 1.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.bueso.giteapr;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
|
||||
@@ -70,25 +70,45 @@ final class InboxViewModel: ObservableObject {
|
||||
|
||||
do {
|
||||
let repos = try await client.repos()
|
||||
// Fetch open PRs across all repos concurrently.
|
||||
LogStore.shared.log(.info, "Inbox: \(repos.count) repos")
|
||||
// Fetch open PRs across all repos concurrently. A single repo that
|
||||
// fails (e.g. 404) must NOT wipe the whole inbox — catch per-repo
|
||||
// errors, log them, and keep whatever succeeded.
|
||||
var rows: [PRRow] = []
|
||||
try await withThrowingTaskGroup(of: [PRRow].self) { group in
|
||||
var failures: [String] = []
|
||||
await withTaskGroup(of: Result<[PRRow], Error>.self) { group in
|
||||
for repo in repos {
|
||||
group.addTask {
|
||||
let prs = try await client.pullRequests(owner: repo.owner.login,
|
||||
repo: repo.name)
|
||||
return try await self.enrich(prs, in: repo, client: client)
|
||||
do {
|
||||
let prs = try await client.pullRequests(owner: repo.owner.login,
|
||||
repo: repo.name)
|
||||
return .success(try await self.enrich(prs, in: repo, client: client))
|
||||
} catch {
|
||||
return .failure(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
for try await batch in group {
|
||||
rows.append(contentsOf: batch)
|
||||
for await result in group {
|
||||
switch result {
|
||||
case .success(let batch):
|
||||
rows.append(contentsOf: batch)
|
||||
case .failure(let error):
|
||||
failures.append(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !failures.isEmpty {
|
||||
LogStore.shared.log(.warn, "Inbox: \(failures.count) repo(s) failed: \(failures.joined(separator: "; "))")
|
||||
}
|
||||
// Sort: most recently updated first.
|
||||
rows.sort { ($0.pr.updatedAt ?? .distantPast) > ($1.pr.updatedAt ?? .distantPast) }
|
||||
allRows = rows
|
||||
recomputeDisplayed()
|
||||
if rows.isEmpty && !failures.isEmpty {
|
||||
errorMessage = "Couldn't load any PRs (\(failures.count) repo(s) failed). Check the Logs screen."
|
||||
}
|
||||
} catch {
|
||||
LogStore.shared.log(.error, "Inbox load failed: \(error.localizedDescription)")
|
||||
errorMessage = error.localizedDescription
|
||||
allRows = []
|
||||
recomputeDisplayed()
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// In-app diagnostic log. Keeps a bounded in-memory ring buffer and persists
|
||||
/// the last N entries to UserDefaults so they survive relaunches and can be
|
||||
/// shared from the Logs screen in Settings.
|
||||
@MainActor
|
||||
final class LogStore: ObservableObject {
|
||||
static let shared = LogStore()
|
||||
|
||||
struct Entry: Identifiable, Equatable {
|
||||
let id: UUID
|
||||
let timestamp: Date
|
||||
let level: Level
|
||||
let message: String
|
||||
|
||||
enum Level: String {
|
||||
case info = "INFO"
|
||||
case warn = "WARN"
|
||||
case error = "ERROR"
|
||||
}
|
||||
}
|
||||
|
||||
@Published private(set) var entries: [Entry] = []
|
||||
|
||||
private let maxEntries = 500
|
||||
private let persistKey = "giteapr.log.entries"
|
||||
|
||||
private init() {
|
||||
load()
|
||||
}
|
||||
|
||||
func log(_ level: Entry.Level, _ message: String) {
|
||||
let entry = Entry(id: UUID(), timestamp: Date(), level: level, message: message)
|
||||
entries.append(entry)
|
||||
if entries.count > maxEntries {
|
||||
entries.removeFirst(entries.count - maxEntries)
|
||||
}
|
||||
persist()
|
||||
}
|
||||
|
||||
func clear() {
|
||||
entries.removeAll()
|
||||
UserDefaults.standard.removeObject(forKey: persistKey)
|
||||
}
|
||||
|
||||
/// Plain-text rendering for sharing / copy.
|
||||
var text: String {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
return entries.map { entry in
|
||||
"[\(f.string(from: entry.timestamp))] \(entry.level.rawValue) \(entry.message)"
|
||||
}.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
let payload = entries.map { entry in
|
||||
["t": f.string(from: entry.timestamp),
|
||||
"l": entry.level.rawValue,
|
||||
"m": entry.message]
|
||||
}
|
||||
if let data = try? JSONSerialization.data(withJSONObject: payload) {
|
||||
UserDefaults.standard.set(data, forKey: persistKey)
|
||||
}
|
||||
}
|
||||
|
||||
private func load() {
|
||||
guard let data = UserDefaults.standard.data(forKey: persistKey),
|
||||
let payload = try? JSONSerialization.jsonObject(with: data) as? [[String: String]] else {
|
||||
return
|
||||
}
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
entries = payload.compactMap { dict in
|
||||
guard let t = dict["t"], let date = f.date(from: t),
|
||||
let l = dict["l"], let m = dict["m"],
|
||||
let level = Entry.Level(rawValue: l) else { return nil }
|
||||
return Entry(id: UUID(), timestamp: date, level: level, message: m)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,18 +78,23 @@ struct GiteaClient {
|
||||
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
LogStore.shared.log(.error, "\(method) \(path) → invalid response")
|
||||
throw APIError.invalidResponse
|
||||
}
|
||||
switch http.statusCode {
|
||||
case 200...299:
|
||||
LogStore.shared.log(.info, "\(method) \(path) → \(http.statusCode)")
|
||||
return data
|
||||
case 401, 403:
|
||||
LogStore.shared.log(.error, "\(method) \(path) → \(http.statusCode) unauthorized")
|
||||
throw APIError.unauthorized
|
||||
case 404:
|
||||
LogStore.shared.log(.error, "\(method) \(path) → 404 not found")
|
||||
throw APIError.notFound
|
||||
default:
|
||||
let message = (try? Self.decoder.decode([String: String].self,
|
||||
from: data))?["message"]
|
||||
LogStore.shared.log(.error, "\(method) \(path) → \(http.statusCode) \(message ?? "")")
|
||||
throw APIError.httpStatus(http.statusCode, message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// Diagnostic log viewer. Shows the in-memory/persisted log entries with a
|
||||
/// Share button (so the user can send logs to Hermes) and a Clear button.
|
||||
struct LogsView: View {
|
||||
@ObservedObject private var store = LogStore.shared
|
||||
@State private var showShare = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if store.entries.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No logs yet",
|
||||
systemImage: "doc.text",
|
||||
description: Text("Run the app and reproduce the issue, then come back here."))
|
||||
} else {
|
||||
List {
|
||||
ForEach(store.entries.reversed()) { entry in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack {
|
||||
Text(entry.level.rawValue)
|
||||
.font(.caption2.weight(.bold))
|
||||
.foregroundColor(color(for: entry.level))
|
||||
Text(entry.timestamp, style: .time)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Text(entry.message)
|
||||
.font(.caption.monospaced())
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Logs")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showShare = true
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
.disabled(store.entries.isEmpty)
|
||||
}
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Clear") { store.clear() }
|
||||
.disabled(store.entries.isEmpty)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showShare) {
|
||||
ShareSheet(text: store.text)
|
||||
}
|
||||
}
|
||||
|
||||
private func color(for level: LogStore.Entry.Level) -> Color {
|
||||
switch level {
|
||||
case .info: return .secondary
|
||||
case .warn: return .orange
|
||||
case .error: return .red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal share sheet wrapper (ShareLink is iOS 16+; this is explicit).
|
||||
struct ShareSheet: UIViewControllerRepresentable {
|
||||
let text: String
|
||||
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(activityItems: [text], applicationActivities: nil)
|
||||
}
|
||||
func updateUIViewController(_ uiViewController: UIActivityViewController,
|
||||
context: Context) {}
|
||||
}
|
||||
@@ -51,6 +51,12 @@ struct SettingsView: View {
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Section("Diagnostics") {
|
||||
NavigationLink("View logs") {
|
||||
LogsView()
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Log out", role: .destructive) {
|
||||
showLogoutConfirm = true
|
||||
|
||||
Reference in New Issue
Block a user