diff --git a/Outpost/Features/Account/SettingsSidebarList.swift b/Outpost/Features/Account/SettingsSidebarList.swift index 0330ad9..291bdd4 100644 --- a/Outpost/Features/Account/SettingsSidebarList.swift +++ b/Outpost/Features/Account/SettingsSidebarList.swift @@ -125,7 +125,7 @@ struct SettingsSidebarList: View { private func refreshOutlineVersion() async { guard isEffectivelyOnline, let apiClient = session.apiClient else { return } - outlineVersion = try? await apiClient.installationInfo().version + outlineVersion = try? await RetryPolicy.withRetry({ try await apiClient.installationInfo().version }) } } #endif diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift index f57daf3..6213f0c 100644 --- a/Outpost/Features/Account/SettingsView.swift +++ b/Outpost/Features/Account/SettingsView.swift @@ -1511,7 +1511,7 @@ struct SettingsView: View { private func refreshProfile() async { guard let apiClient = session.apiClient else { return } - guard let fresh = try? await apiClient.currentUser() else { return } + guard let fresh = try? await RetryPolicy.withRetry({ try await apiClient.currentUser() }) else { return } session.applyUpdatedProfile(fresh) } @@ -1543,7 +1543,7 @@ struct SettingsView: View { // Best-effort — the new avatar is already live either way, this // just stops the old upload from sitting around unreferenced. if let previousAttachmentId { - try? await apiClient.deleteAttachment(id: previousAttachmentId) + try? await RetryPolicy.withRetry({ try await apiClient.deleteAttachment(id: previousAttachmentId) }) } } catch { avatarErrorMessage = outlineErrorMessage(error, fallback: "Couldn't upload this photo.") @@ -1560,7 +1560,7 @@ struct SettingsView: View { session.applyUpdatedProfile(updated) avatarErrorMessage = nil if let previousAttachmentId { - try? await apiClient.deleteAttachment(id: previousAttachmentId) + try? await RetryPolicy.withRetry({ try await apiClient.deleteAttachment(id: previousAttachmentId) }) } } catch { avatarErrorMessage = outlineErrorMessage(error, fallback: "Couldn't remove this photo.") diff --git a/Outpost/Features/Collections/CollectionDocumentsOutline.swift b/Outpost/Features/Collections/CollectionDocumentsOutline.swift index 70832b9..f05baf5 100644 --- a/Outpost/Features/Collections/CollectionDocumentsOutline.swift +++ b/Outpost/Features/Collections/CollectionDocumentsOutline.swift @@ -90,7 +90,7 @@ struct CollectionDocumentsOutline: View { } private func loadPins() async { - guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: collection.id)) else { return } + guard let pins = try? await RetryPolicy.withRetry({ try await apiClient.listPins(ListPinsRequest(collectionId: collection.id)) }) else { return } pinsByDocumentID = Dictionary(uniqueKeysWithValues: pins.map { ($0.documentId, $0) }) } } diff --git a/Outpost/Features/Collections/DocumentCommentsSheet.swift b/Outpost/Features/Collections/DocumentCommentsSheet.swift index 8cd446e..bdb0284 100644 --- a/Outpost/Features/Collections/DocumentCommentsSheet.swift +++ b/Outpost/Features/Collections/DocumentCommentsSheet.swift @@ -123,7 +123,7 @@ struct DocumentCommentsSheet: View { } .frame(width: 480, height: 560) .task { await load() } - .task { currentUserId = try? await apiClient.currentUser().id } + .task { currentUserId = try? await RetryPolicy.withRetry({ try await apiClient.currentUser().id }) } .task { composingAnchorText = pendingAnchorText } .alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) { Button("OK") { actionErrorMessage = nil } diff --git a/Outpost/Features/Collections/DocumentReaderView.swift b/Outpost/Features/Collections/DocumentReaderView.swift index a1b3643..f28f509 100644 --- a/Outpost/Features/Collections/DocumentReaderView.swift +++ b/Outpost/Features/Collections/DocumentReaderView.swift @@ -328,9 +328,11 @@ struct DocumentReaderView: View { await viewModel.loadInsightsEnabledState() } .task { - loadedComments = (try? await apiClient.listComments( - ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true) - )) ?? [] + loadedComments = (try? await RetryPolicy.withRetry({ + try await apiClient.listComments( + ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true) + ) + })) ?? [] } .task { while !Task.isCancelled { @@ -387,9 +389,11 @@ struct DocumentReaderView: View { pendingAnchorText: pendingCommentAnchorText, onCommentsChanged: { Task { - loadedComments = (try? await apiClient.listComments( - ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true) - )) ?? loadedComments + loadedComments = (try? await RetryPolicy.withRetry({ + try await apiClient.listComments( + ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true) + ) + })) ?? loadedComments } } ) diff --git a/Outpost/Features/Collections/DocumentReaderViewModel.swift b/Outpost/Features/Collections/DocumentReaderViewModel.swift index 59ad5ff..374dcc4 100644 --- a/Outpost/Features/Collections/DocumentReaderViewModel.swift +++ b/Outpost/Features/Collections/DocumentReaderViewModel.swift @@ -111,14 +111,18 @@ final class DocumentReaderViewModel { } func loadViewers() async { - guard let views = try? await apiClient.listViews(ListViewsRequest(documentId: documentId)) else { return } + // A single blip here used to just leave `viewers` empty forever with + // no sign anything went wrong — retry-with-backoff absorbs that; + // `try?` still covers the "still failing after retries" case, same + // silent-but-harmless fallback as before (an empty viewers list). + guard let views = try? await RetryPolicy.withRetry({ try await apiClient.listViews(ListViewsRequest(documentId: documentId)) }) else { return } viewers = views.filter { $0.lastViewedAt != nil } } func loadPinAndSubscriptionState() async { // `collectionId: nil` = Home pins. This menu's Pin action is "Pin to // Home", not "Pin to Collection" — those are distinct on the server. - if let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)), + if let pins = try? await RetryPolicy.withRetry({ try await apiClient.listPins(ListPinsRequest(collectionId: nil)) }), let match = pins.first(where: { $0.documentId == documentId }) { isPinned = true pinId = match.id @@ -127,7 +131,7 @@ final class DocumentReaderViewModel { pinId = nil } - if let subscriptions = try? await apiClient.listSubscriptions(ListSubscriptionsRequest(documentId: documentId)), + if let subscriptions = try? await RetryPolicy.withRetry({ try await apiClient.listSubscriptions(ListSubscriptionsRequest(documentId: documentId)) }), let match = subscriptions.first { isSubscribed = true subscriptionId = match.id diff --git a/Outpost/Features/Collections/DocumentShareSheet.swift b/Outpost/Features/Collections/DocumentShareSheet.swift index d259b34..58ccfc4 100644 --- a/Outpost/Features/Collections/DocumentShareSheet.swift +++ b/Outpost/Features/Collections/DocumentShareSheet.swift @@ -370,7 +370,7 @@ struct DocumentShareSheet: View { private func loadMembers() async { isLoadingMembers = true defer { isLoadingMembers = false } - members = (try? await apiClient.documentUsers(ListDocumentUsersRequest(id: documentId))) ?? [] + members = (try? await RetryPolicy.withRetry({ try await apiClient.documentUsers(ListDocumentUsersRequest(id: documentId)) })) ?? [] } private func searchUsers(_ query: String) async { @@ -381,7 +381,7 @@ struct DocumentShareSheet: View { } isSearchingUsers = true defer { isSearchingUsers = false } - userSearchResults = (try? await apiClient.listUsers(ListUsersRequest(query: trimmed))) ?? [] + userSearchResults = (try? await RetryPolicy.withRetry({ try await apiClient.listUsers(ListUsersRequest(query: trimmed)) })) ?? [] } private func addUser(_ user: OutlineUser) async { diff --git a/Outpost/Features/Home/HomeViewModel.swift b/Outpost/Features/Home/HomeViewModel.swift index eb35471..01f6ae8 100644 --- a/Outpost/Features/Home/HomeViewModel.swift +++ b/Outpost/Features/Home/HomeViewModel.swift @@ -85,7 +85,7 @@ final class HomeViewModel { /// set (unlike a full collection tree), so the N+1 here is acceptable /// where it wouldn't be in the sidebar. private func fetchPinnedThrowing() async throws -> [OutlineDocument] { - let pins = try await apiClient.listPins(ListPinsRequest(collectionId: nil)) + let pins = try await RetryPolicy.withRetry { try await apiClient.listPins(ListPinsRequest(collectionId: nil)) } var documents: [OutlineDocument] = [] for pin in pins { if let document = try? await apiClient.documentInfo(id: pin.documentId) { @@ -134,7 +134,7 @@ final class HomeViewModel { private func resolveCurrentUserID() async throws -> String { if let currentUserID { return currentUserID } - let user = try await apiClient.currentUser() + let user = try await RetryPolicy.withRetry { try await apiClient.currentUser() } currentUserID = user.id return user.id } diff --git a/Outpost/Root/APIFailureCenter.swift b/Outpost/Root/APIFailureCenter.swift new file mode 100644 index 0000000..40970fa --- /dev/null +++ b/Outpost/Root/APIFailureCenter.swift @@ -0,0 +1,66 @@ +import Observation +import OutlineKit + +/// Turns `CachingOutlineAPIClient.repeatedFailureSummaries()` into a banner +/// the user can actually see and act on, instead of a silently-swallowed +/// `try?` — see the pins bug this whole mechanism exists to catch a repeat +/// of. `RootView` polls the client periodically and feeds results in via +/// `update(with:)`; nothing here talks to the network directly. +@MainActor +@Observable +final class APIFailureCenter { + /// The single most-relevant category to show right now, or nil if + /// nothing's currently past the threshold (or everything past it has + /// been dismissed and is still in its cooldown). + private(set) var activeBanner: RepeatedFailure? + + /// Categories the user's already dismissed, and when — suppressed from + /// reappearing until `dismissCooldown` passes, so a still-flaky + /// operation doesn't pop the same banner right back up a few seconds + /// after being told to go away. + private var dismissedAt: [String: Date] = [:] + private let dismissCooldown: TimeInterval = 900 + + /// Called from `RootView`'s poll loop with the latest snapshot from + /// `CachingOutlineAPIClient`. Picks the worst-offending category + /// (highest failure count) that isn't in cooldown; clears the banner + /// entirely once nothing qualifies (e.g. the user went back online and + /// everything recovered). + func update(with summaries: [RepeatedFailure]) { + let now = Date() + dismissedAt = dismissedAt.filter { now.timeIntervalSince($0.value) < dismissCooldown } + + let eligible = summaries + .filter { dismissedAt[$0.category] == nil } + .sorted { $0.count > $1.count } + + activeBanner = eligible.first + } + + /// Dismiss without reporting — starts that category's cooldown so it + /// won't immediately reappear on the next poll if it's still failing. + func dismiss() { + guard let category = activeBanner?.category else { return } + dismissedAt[category] = Date() + activeBanner = nil + } + + /// Everything folded into the report is safe to paste into a public bug + /// tracker as-is: a category name, a generic error description, and + /// version numbers — no document content, no server URL, no token. + func reportURL(appVersion: String, osVersion: String) -> URL? { + guard let banner = activeBanner else { return nil } + var components = URLComponents(string: "https://git.psmattas.com/psmattas/Outpost/issues/new") + let body = """ + Outpost kept failing to \(banner.category) (\(banner.count) times in the last few minutes). + + Error: \(banner.message) + App version: \(appVersion) + macOS: \(osVersion) + + + """ + components?.queryItems = [URLQueryItem(name: "body", value: body)] + return components?.url + } +} diff --git a/Outpost/Root/RepeatedFailureBanner.swift b/Outpost/Root/RepeatedFailureBanner.swift new file mode 100644 index 0000000..49d27a8 --- /dev/null +++ b/Outpost/Root/RepeatedFailureBanner.swift @@ -0,0 +1,38 @@ +#if os(macOS) +import SwiftUI + +/// Shown when the same category of API call has failed repeatedly within a +/// few minutes (see `APIFailureCenter`) — the self-diagnosing replacement +/// for a `try?` that used to fail silently. No manual "Retry" button: the +/// retries already happened automatically before this ever appears, so all +/// that's left worth offering is reporting it and moving on. +struct RepeatedFailureBanner: View { + let message: String + let onReport: () -> Void + let onDismiss: () -> Void + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.orange) + Text(message) + .font(.callout) + .lineLimit(2) + Spacer(minLength: 8) + Button("Report", action: onReport) + .buttonStyle(.borderedProminent) + .controlSize(.small) + Button { + onDismiss() + } label: { + Image(systemName: "xmark") + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.orange.opacity(0.12)) + } +} +#endif diff --git a/Outpost/Root/RootView.swift b/Outpost/Root/RootView.swift index 5fd90d4..50f92c4 100644 --- a/Outpost/Root/RootView.swift +++ b/Outpost/Root/RootView.swift @@ -3,8 +3,10 @@ import OutlineKit struct RootView: View { @Environment(SessionStore.self) private var session + @Environment(\.openURL) private var openURL @State private var welcomeName: String? @State private var starStore = StarStore() + @State private var failureCenter = APIFailureCenter() @AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false @@ -34,10 +36,39 @@ struct RootView: View { } } .environment(starStore) + .environment(failureCenter) + #if os(macOS) + .overlay(alignment: .top) { + if let banner = failureCenter.activeBanner { + RepeatedFailureBanner( + message: bannerMessage(for: banner), + onReport: { reportActiveFailure() }, + onDismiss: { failureCenter.dismiss() } + ) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.2), value: failureCenter.activeBanner) + #endif .animation(.easeInOut(duration: 0.45), value: welcomeName != nil) .task { await session.refreshTeamInfoIfNeeded() } + // Repeated (non-transient) API failures already get an automatic + // retry-with-backoff inside CachingOutlineAPIClient itself — this + // just surfaces the ones that kept failing anyway, on a cheap poll + // (the client's own state, no network call of its own) rather than + // a push, since the client is a plain actor with no UI dependency. + .task(id: session.isSignedIn) { + guard session.isSignedIn else { return } + while !Task.isCancelled { + if let cachingClient = session.cachingClient { + let summaries = await cachingClient.repeatedFailureSummaries() + failureCenter.update(with: summaries) + } + try? await Task.sleep(for: .seconds(30)) + } + } .task(id: session.isSignedIn) { if session.isSignedIn, let apiClient = session.apiClient { await starStore.load(apiClient: apiClient) @@ -89,6 +120,35 @@ struct RootView: View { } } + #if os(macOS) + /// Category names are internal plumbing (`documents-write`, `pins`, + /// `collections-write`, ...) — this is the one place they turn into + /// something a user reads, so a new category added later just needs a + /// case here, not a rewrite of the tracking/polling underneath it. + private func bannerMessage(for failure: RepeatedFailure) -> String { + switch failure.category { + case "documents-write": return "Outpost is having trouble saving your document edits." + case "collections-write": return "Outpost is having trouble saving collection changes." + case "pins": return "Outpost is having trouble updating pins." + case "subscriptions": return "Outpost is having trouble updating subscriptions." + case "stars": return "Outpost is having trouble updating stars." + case "document", "documents": return "Outpost is having trouble loading documents." + case "collections": return "Outpost is having trouble loading collections." + case "drafts": return "Outpost is having trouble loading drafts." + default: return "Outpost is having trouble talking to the server (\(failure.category))." + } + } + + private func reportActiveFailure() { + guard let url = failureCenter.reportURL( + appVersion: OutpostVersion.displayString, + osVersion: ProcessInfo.processInfo.operatingSystemVersionString + ) else { return } + openURL(url) + failureCenter.dismiss() + } + #endif + private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) { welcomeName = result.user.name session.signIn(serverURL: result.serverURL, user: result.user, team: result.team) diff --git a/Outpost/Root/SessionStore.swift b/Outpost/Root/SessionStore.swift index a75a04b..fa14818 100644 --- a/Outpost/Root/SessionStore.swift +++ b/Outpost/Root/SessionStore.swift @@ -136,7 +136,7 @@ final class SessionStore { /// in-memory state didn't. func refreshTeamInfoIfNeeded() async { guard isSignedIn, teamName == nil, let apiClient, let serverURL else { return } - guard let auth = try? await apiClient.authInfo() else { return } + guard let auth = try? await RetryPolicy.withRetry({ try await apiClient.authInfo() }) else { return } apply(user: auth.user, team: auth.team, serverURL: serverURL) } diff --git a/Outpost/Support/StarStore.swift b/Outpost/Support/StarStore.swift index 05bac17..c52b7d3 100644 --- a/Outpost/Support/StarStore.swift +++ b/Outpost/Support/StarStore.swift @@ -22,7 +22,7 @@ final class StarStore { } func load(apiClient: OutlineAPIClient) async { - guard let stars = try? await apiClient.listStars(ListStarsRequest(offset: 0, limit: 250)) else { return } + guard let stars = try? await RetryPolicy.withRetry({ try await apiClient.listStars(ListStarsRequest(offset: 0, limit: 250)) }) else { return } documentStars = Dictionary(uniqueKeysWithValues: stars.compactMap { star in star.documentId.map { ($0, star) } })