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:
1 parent
43f4d76356
commit
b51b2ee5c8
6 files changed
+202
-11
No files matched your search
@@ -187,7 +187,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
CURRENT_PROJECT_VERSION = 3;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "GiteaPR";
|
INFOPLIST_KEY_CFBundleDisplayName = "GiteaPR";
|
||||||
@@ -197,7 +197,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1;
|
MARKETING_VERSION = 1.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.bueso.giteapr;
|
PRODUCT_BUNDLE_IDENTIFIER = com.bueso.giteapr;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
@@ -211,7 +211,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
CURRENT_PROJECT_VERSION = 3;
|
||||||
DEVELOPMENT_TEAM = "";
|
DEVELOPMENT_TEAM = "";
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "GiteaPR";
|
INFOPLIST_KEY_CFBundleDisplayName = "GiteaPR";
|
||||||
@@ -221,7 +221,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.1;
|
MARKETING_VERSION = 1.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.bueso.giteapr;
|
PRODUCT_BUNDLE_IDENTIFIER = com.bueso.giteapr;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
|||||||
@@ -70,25 +70,45 @@ final class InboxViewModel: ObservableObject {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let repos = try await client.repos()
|
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] = []
|
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 {
|
for repo in repos {
|
||||||
group.addTask {
|
group.addTask {
|
||||||
let prs = try await client.pullRequests(owner: repo.owner.login,
|
do {
|
||||||
repo: repo.name)
|
let prs = try await client.pullRequests(owner: repo.owner.login,
|
||||||
return try await self.enrich(prs, in: repo, client: client)
|
repo: repo.name)
|
||||||
|
return .success(try await self.enrich(prs, in: repo, client: client))
|
||||||
|
} catch {
|
||||||
|
return .failure(error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for try await batch in group {
|
for await result in group {
|
||||||
rows.append(contentsOf: batch)
|
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.
|
// Sort: most recently updated first.
|
||||||
rows.sort { ($0.pr.updatedAt ?? .distantPast) > ($1.pr.updatedAt ?? .distantPast) }
|
rows.sort { ($0.pr.updatedAt ?? .distantPast) > ($1.pr.updatedAt ?? .distantPast) }
|
||||||
allRows = rows
|
allRows = rows
|
||||||
recomputeDisplayed()
|
recomputeDisplayed()
|
||||||
|
if rows.isEmpty && !failures.isEmpty {
|
||||||
|
errorMessage = "Couldn't load any PRs (\(failures.count) repo(s) failed). Check the Logs screen."
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
LogStore.shared.log(.error, "Inbox load failed: \(error.localizedDescription)")
|
||||||
errorMessage = error.localizedDescription
|
errorMessage = error.localizedDescription
|
||||||
allRows = []
|
allRows = []
|
||||||
recomputeDisplayed()
|
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)
|
let (data, response) = try await session.data(for: req)
|
||||||
guard let http = response as? HTTPURLResponse else {
|
guard let http = response as? HTTPURLResponse else {
|
||||||
|
LogStore.shared.log(.error, "\(method) \(path) → invalid response")
|
||||||
throw APIError.invalidResponse
|
throw APIError.invalidResponse
|
||||||
}
|
}
|
||||||
switch http.statusCode {
|
switch http.statusCode {
|
||||||
case 200...299:
|
case 200...299:
|
||||||
|
LogStore.shared.log(.info, "\(method) \(path) → \(http.statusCode)")
|
||||||
return data
|
return data
|
||||||
case 401, 403:
|
case 401, 403:
|
||||||
|
LogStore.shared.log(.error, "\(method) \(path) → \(http.statusCode) unauthorized")
|
||||||
throw APIError.unauthorized
|
throw APIError.unauthorized
|
||||||
case 404:
|
case 404:
|
||||||
|
LogStore.shared.log(.error, "\(method) \(path) → 404 not found")
|
||||||
throw APIError.notFound
|
throw APIError.notFound
|
||||||
default:
|
default:
|
||||||
let message = (try? Self.decoder.decode([String: String].self,
|
let message = (try? Self.decoder.decode([String: String].self,
|
||||||
from: data))?["message"]
|
from: data))?["message"]
|
||||||
|
LogStore.shared.log(.error, "\(method) \(path) → \(http.statusCode) \(message ?? "")")
|
||||||
throw APIError.httpStatus(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)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section("Diagnostics") {
|
||||||
|
NavigationLink("View logs") {
|
||||||
|
LogsView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
Button("Log out", role: .destructive) {
|
Button("Log out", role: .destructive) {
|
||||||
showLogoutConfirm = true
|
showLogoutConfirm = true
|
||||||
|
|||||||
Reference in new issue
Block a user