From aa02153b5d1dd1d3e5867b483b0771a73b5465bc Mon Sep 17 00:00:00 2001 From: psmattas Date: Fri, 21 Aug 2026 00:34:53 +0100 Subject: [PATCH 01/12] chore: hide unbuilt Workspace section and Advanced coming-soon rows App Store review won't accept a settings section that's just "Coming Soon" placeholders. Workspace (all 14 sub-sections: details, authentication, security, ai, members, groups, templates, emojis, applications, shared, links, webhooks, importData, exportData) had zero built content, so it's filtered out of SettingsSidebarList entirely rather than shown with a Coming Soon badge - via a new visibleCategories helper (categories with at least one isImplemented section), not by touching the SettingsSection enum itself, so nothing else that switches over it needs to change. Advanced's three comingSoonRow placeholders (Export All Data, Developer Diagnostics, Reset Local Database) are commented out the same way, comingSoonRow() itself kept (unused for now) so re-enabling either is a one-line job once real content lands. Both marked TODO. --- .../Features/Account/SettingsSidebarList.swift | 18 +++++++++++++++++- Outpost/Features/Account/SettingsView.swift | 14 +++++++++++--- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/Outpost/Features/Account/SettingsSidebarList.swift b/Outpost/Features/Account/SettingsSidebarList.swift index 9f86f5d..0330ad9 100644 --- a/Outpost/Features/Account/SettingsSidebarList.swift +++ b/Outpost/Features/Account/SettingsSidebarList.swift @@ -42,7 +42,15 @@ struct SettingsSidebarList: View { Divider() List(selection: $selection) { - ForEach(SettingsCategory.allCases) { category in + // TODO: Workspace is entirely `!isImplemented` placeholders + // right now (details/authentication/security/ai/members/ + // groups/templates/emojis/applications/shared/links/ + // webhooks/importData/exportData) — App Store review won't + // accept a section that's just "Coming Soon" rows, so it's + // filtered out of the sidebar below (via `visibleCategories`) + // until real content lands. Remove the filter once at least + // one Workspace section is built. + ForEach(visibleCategories) { category in let sections = SettingsSection.allCases.filter { $0.category == category } Section { ForEach(sections) { section in @@ -91,6 +99,14 @@ struct SettingsSidebarList: View { .task(id: isEffectivelyOnline) { await refreshOutlineVersion() } } + /// Categories with at least one built (`isImplemented`) section — see the + /// TODO above the `ForEach` that uses this. + private var visibleCategories: [SettingsCategory] { + SettingsCategory.allCases.filter { category in + SettingsSection.allCases.contains { $0.category == category && $0.isImplemented } + } + } + private var versionFooter: some View { VStack(alignment: .leading, spacing: 2) { Text("Outpost \(OutpostVersion.displayString)") diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift index d5181cb..f57daf3 100644 --- a/Outpost/Features/Account/SettingsView.swift +++ b/Outpost/Features/Account/SettingsView.swift @@ -1325,9 +1325,10 @@ struct SettingsView: View { } .frame(maxWidth: 480, alignment: .leading) - Divider() - .frame(maxWidth: 480) - + // TODO: Export All Data / Developer Diagnostics / Reset Local + // Database aren't built yet — App Store review won't accept + // "Coming Soon" rows, so commented out until real content lands. + /* VStack(alignment: .leading, spacing: 12) { comingSoonRow("Export All Data") comingSoonRow("Developer Diagnostics") @@ -1335,6 +1336,10 @@ struct SettingsView: View { } .frame(maxWidth: 480, alignment: .leading) + Divider() + .frame(maxWidth: 480) + */ + Divider() .frame(maxWidth: 480) @@ -1400,6 +1405,9 @@ struct SettingsView: View { ) } + /// Only referenced from the commented-out block above right now — kept + /// (not deleted) so re-enabling those rows is a one-line uncomment once + /// they're actually built. private func comingSoonRow(_ title: String) -> some View { HStack { Text(title) From 512c6d22bf7ad33e172ffad85d33c186f4eb148b Mon Sep 17 00:00:00 2001 From: psmattas Date: Fri, 21 Aug 2026 00:41:26 +0100 Subject: [PATCH 02/12] feat(outlinekit): auto-retry with backoff and repeated-failure tracking Foundation for turning the app's try?-swallowed API failures (see the pins bug) into something self-diagnosing instead of silent, without a manual "Retry" button nagging the user for every blip. RetryPolicy.withRetry wraps a call with exponential backoff, but only for OutlineAPIError.transport - a decode/auth/server error will look identical on a second try, so those fail immediately instead of burning the cooldown window. CachingOutlineAPIClient now runs every live call (both the cached-read path and the queueable-write path) through it, and keeps a per-category sliding-window failure log: repeatedFailureSummaries() surfaces a category only once it's failed 3+ times in 5 minutes with a structural (non-transport) error - plain connectivity loss is deliberately excluded since that already has its own offline UI elsewhere, and logging it here too would just be a redundant second banner every time Wi-Fi drops. Pull-based (polled), not push - this actor has no UI dependency of its own, so the Outpost-side banner reads this periodically instead of the client taking a callback. Categories are coarse (documents, collections, pins, subscriptions, stars, drafts, etc.) and the summaries carry no document content or server URL, only a generic error description - safe to show a user or attach to a bug report as-is. 10 new tests (RetryPolicyTests + CachingOutlineAPIClientTests), 92/92 passing overall. --- .../Caching/CachingOutlineAPIClient.swift | 137 +++++++++++++++--- .../OutlineKit/Models/RepeatedFailure.swift | 19 +++ .../OutlineKit/Support/RetryPolicy.swift | 38 +++++ .../CachingOutlineAPIClientTests.swift | 98 +++++++++++++ .../OutlineKitTests/RetryPolicyTests.swift | 69 +++++++++ 5 files changed, 342 insertions(+), 19 deletions(-) create mode 100644 OutlineKit/Sources/OutlineKit/Models/RepeatedFailure.swift create mode 100644 OutlineKit/Sources/OutlineKit/Support/RetryPolicy.swift create mode 100644 OutlineKit/Tests/OutlineKitTests/RetryPolicyTests.swift diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift index 3c9bf20..0342b49 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift @@ -37,6 +37,16 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { private let decoder: JSONDecoder private let keyEncoder: JSONEncoder + /// Recent failure timestamps per category — see `recordFailure` / + /// `repeatedFailureSummaries()`. A category only shows up there once it's + /// failed `failureThreshold` times within `failureWindow`; a single + /// transient blip (which `RetryPolicy` already tries to absorb) never + /// reaches this at all. + private var failureLog: [String: [Date]] = [:] + private var lastFailureMessage: [String: String] = [:] + private let failureWindow: TimeInterval = 300 + private let failureThreshold: Int = 3 + public init(live: OutlineAPIClient, cache: OfflineCacheStore, defaults: UserDefaults = .standard) { self.live = live self.cache = cache @@ -62,7 +72,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { // MARK: - Cached reads public func documentInfo(id: String) async throws -> OutlineDocument { - try await cachedFetch(key: "document:\(id)") { try await self.live.documentInfo(id: id) } + try await cachedFetch(key: "document:\(id)", category: "document") { try await self.live.documentInfo(id: id) } } public func listDocuments( @@ -72,7 +82,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { limit: Int ) async throws -> [OutlineDocument] { let key = "documents:\(collectionId ?? "-"):\(parentDocumentId ?? "-"):\(offset):\(limit)" - return try await cachedFetch(key: key) { + return try await cachedFetch(key: key, category: "documents") { try await self.live.listDocuments( collectionId: collectionId, parentDocumentId: parentDocumentId, @@ -83,29 +93,29 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { } public func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { - try await cachedFetch(key: requestKey("documentsList", request)) { try await self.live.documentsList(request) } + try await cachedFetch(key: requestKey("documentsList", request), category: "documents") { try await self.live.documentsList(request) } } public func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { - try await cachedFetch(key: "documentsViewed:\(offset):\(limit)") { + try await cachedFetch(key: "documentsViewed:\(offset):\(limit)", category: "documents-viewed") { try await self.live.listViewedDocuments(offset: offset, limit: limit) } } public func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] { - try await cachedFetch(key: "documentsDrafts:\(request.offset):\(request.limit)") { + try await cachedFetch(key: "documentsDrafts:\(request.offset):\(request.limit)", category: "drafts") { try await self.live.listDrafts(request) } } public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] { - try await cachedFetch(key: "collections:\(offset):\(limit)") { + try await cachedFetch(key: "collections:\(offset):\(limit)", category: "collections") { try await self.live.listCollections(offset: offset, limit: limit) } } public func collectionInfo(id: String) async throws -> OutlineCollection { - try await cachedFetch(key: "collection:\(id)") { try await self.live.collectionInfo(id: id) } + try await cachedFetch(key: "collection:\(id)", category: "collections") { try await self.live.collectionInfo(id: id) } } // MARK: - Queueable writes @@ -121,10 +131,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument { if !isManualOfflineModeEnabled { do { - let result = try await live.createDocument(request) + let result = try await RetryPolicy.withRetry { try await self.live.createDocument(request) } + recordSuccess(category: "documents-write") await cacheDocument(result) return result } catch { + recordWriteFailureIfStructural(category: "documents-write", error) return await queueDocumentCreate(request) } } @@ -134,10 +146,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument { if !isManualOfflineModeEnabled { do { - let result = try await live.updateDocument(request) + let result = try await RetryPolicy.withRetry { try await self.live.updateDocument(request) } + recordSuccess(category: "documents-write") await cacheDocument(result) return result } catch { + recordWriteFailureIfStructural(category: "documents-write", error) return try await queueDocumentUpdate(request, dueTo: error) } } @@ -147,10 +161,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection { if !isManualOfflineModeEnabled { do { - let result = try await live.updateCollection(request) + let result = try await RetryPolicy.withRetry { try await self.live.updateCollection(request) } + recordSuccess(category: "collections-write") await cacheCollection(result) return result } catch { + recordWriteFailureIfStructural(category: "collections-write", error) return try await queueCollectionUpdate(request, dueTo: error) } } @@ -159,7 +175,14 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin { if !isManualOfflineModeEnabled { - do { return try await live.createPin(request) } catch { return await queuePinCreate(request) } + do { + let result = try await RetryPolicy.withRetry { try await self.live.createPin(request) } + recordSuccess(category: "pins") + return result + } catch { + recordWriteFailureIfStructural(category: "pins", error) + return await queuePinCreate(request) + } } return await queuePinCreate(request) } @@ -168,9 +191,11 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { if await cancelIfNeverSynced(id: id) { return } if !isManualOfflineModeEnabled { do { - try await live.deletePin(id: id) + try await RetryPolicy.withRetry { try await self.live.deletePin(id: id) } + recordSuccess(category: "pins") return } catch { + recordWriteFailureIfStructural(category: "pins", error) await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)") return } @@ -180,7 +205,14 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription { if !isManualOfflineModeEnabled { - do { return try await live.createSubscription(request) } catch { return await queueSubscriptionCreate(request) } + do { + let result = try await RetryPolicy.withRetry { try await self.live.createSubscription(request) } + recordSuccess(category: "subscriptions") + return result + } catch { + recordWriteFailureIfStructural(category: "subscriptions", error) + return await queueSubscriptionCreate(request) + } } return await queueSubscriptionCreate(request) } @@ -189,9 +221,11 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { if await cancelIfNeverSynced(id: id) { return } if !isManualOfflineModeEnabled { do { - try await live.deleteSubscription(id: id) + try await RetryPolicy.withRetry { try await self.live.deleteSubscription(id: id) } + recordSuccess(category: "subscriptions") return } catch { + recordWriteFailureIfStructural(category: "subscriptions", error) await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)") return } @@ -201,14 +235,28 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar { if !isManualOfflineModeEnabled { - do { return try await live.starDocument(request) } catch { return await queueStarDocumentCreate(request) } + do { + let result = try await RetryPolicy.withRetry { try await self.live.starDocument(request) } + recordSuccess(category: "stars") + return result + } catch { + recordWriteFailureIfStructural(category: "stars", error) + return await queueStarDocumentCreate(request) + } } return await queueStarDocumentCreate(request) } public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar { if !isManualOfflineModeEnabled { - do { return try await live.starCollection(request) } catch { return await queueStarCollectionCreate(request) } + do { + let result = try await RetryPolicy.withRetry { try await self.live.starCollection(request) } + recordSuccess(category: "stars") + return result + } catch { + recordWriteFailureIfStructural(category: "stars", error) + return await queueStarCollectionCreate(request) + } } return await queueStarCollectionCreate(request) } @@ -217,9 +265,11 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { if await cancelIfNeverSynced(id: id) { return } if !isManualOfflineModeEnabled { do { - try await live.deleteStar(id: id) + try await RetryPolicy.withRetry { try await self.live.deleteStar(id: id) } + recordSuccess(category: "stars") return } catch { + recordWriteFailureIfStructural(category: "stars", error) await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)") return } @@ -564,7 +614,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { // MARK: - Helpers - private func cachedFetch(key: String, fetch: () async throws -> T) async throws -> T { + private func cachedFetch(key: String, category: String, fetch: () async throws -> T) async throws -> T { // Manual offline mode means "skip the network entirely," not just // "prefer it" — without this check, a read would still hit `live` // (and succeed, showing content beyond whatever's cached) any time @@ -577,12 +627,21 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { throw OutlineAPIError.transport(URLError(.notConnectedToInternet)) } do { - let result = try await fetch() + let result = try await RetryPolicy.withRetry { try await fetch() } + recordSuccess(category: category) if let data = try? encoder.encode(result) { await cache.save(data, forKey: key) } return result } catch { + // Only a structural failure (decode/auth/server — the server + // answered, but something's actually wrong) counts toward the + // repeated-failure log. Plain connectivity loss already has its + // own offline UI elsewhere; logging it here too would just be a + // second banner for the same thing every time Wi-Fi drops. + if !RetryPolicy.isRetryable(error) { + recordFailure(category: category, message: errorDescription(error)) + } if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) { return cached } @@ -788,6 +847,46 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { } } + /// Writes always queue on any failure (existing behavior, unchanged) — + /// this only decides whether the failure is worth logging. A structural + /// error (decode/auth/server) queuing for later replay will likely just + /// fail the same way again next sync; a transient one might not. Either + /// way the queue doesn't change, only whether it's counted toward + /// `repeatedFailureSummaries()`. + private func recordWriteFailureIfStructural(category: String, _ error: Error) { + guard !RetryPolicy.isRetryable(error) else { return } + recordFailure(category: category, message: errorDescription(error)) + } + + private func recordFailure(category: String, message: String) { + let now = Date() + var timestamps = (failureLog[category] ?? []).filter { now.timeIntervalSince($0) < failureWindow } + timestamps.append(now) + failureLog[category] = timestamps + lastFailureMessage[category] = message + } + + private func recordSuccess(category: String) { + failureLog[category] = nil + lastFailureMessage[category] = nil + } + + /// Categories that have failed `failureThreshold`+ times within the last + /// `failureWindow` seconds — meant to be polled periodically (see + /// `RootView`), not pushed, since this actor has no UI-facing dependency + /// of its own. A single blip never shows up here: `RetryPolicy` absorbs + /// transient failures before they're ever logged, and only structural + /// ones (decode/auth/server) get logged at all — see + /// `recordWriteFailureIfStructural` and `cachedFetch`. + public func repeatedFailureSummaries() async -> [RepeatedFailure] { + let now = Date() + return failureLog.compactMap { category, timestamps in + let recent = timestamps.filter { now.timeIntervalSince($0) < failureWindow } + guard recent.count >= failureThreshold, let message = lastFailureMessage[category] else { return nil } + return RepeatedFailure(category: category, message: message, count: recent.count) + } + } + private func errorDescription(_ error: Error) -> String { if let apiError = error as? OutlineAPIError { switch apiError { diff --git a/OutlineKit/Sources/OutlineKit/Models/RepeatedFailure.swift b/OutlineKit/Sources/OutlineKit/Models/RepeatedFailure.swift new file mode 100644 index 0000000..a942f93 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Models/RepeatedFailure.swift @@ -0,0 +1,19 @@ +import Foundation + +/// A category of API call that's failed repeatedly within a short window — +/// see `CachingOutlineAPIClient.repeatedFailureSummaries()`. Deliberately +/// carries no request/response payload, document content, or server URL: +/// this is meant to be safe to show a user or attach to a bug report as-is. +public struct RepeatedFailure: Sendable, Identifiable, Equatable { + public let category: String + public let message: String + public let count: Int + + public var id: String { category } + + public init(category: String, message: String, count: Int) { + self.category = category + self.message = message + self.count = count + } +} diff --git a/OutlineKit/Sources/OutlineKit/Support/RetryPolicy.swift b/OutlineKit/Sources/OutlineKit/Support/RetryPolicy.swift new file mode 100644 index 0000000..dacbd9a --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Support/RetryPolicy.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Automatic retry-with-backoff for API calls, so a single transient network +/// blip doesn't turn into a user-visible failure (or a silently swallowed +/// one) the way one `try?` used to. +/// +/// Only retries `OutlineAPIError.transport` — a dropped connection or +/// timeout might succeed a second later. Everything else (`.decoding`, +/// `.unauthorized`, `.notFound`, `.server`, `.tokenUnavailable`) is retried +/// zero times: a response-shape mismatch or a 404 will look exactly the same +/// on attempt two, so retrying just burns the cooldown window for nothing — +/// callers should treat those as immediate failures instead. +public enum RetryPolicy { + public static func withRetry( + maxAttempts: Int = 3, + initialDelay: Duration = .seconds(1), + operation: () async throws -> T + ) async throws -> T { + var attempt = 1 + var delay = initialDelay + while true { + do { + return try await operation() + } catch { + guard attempt < maxAttempts, isRetryable(error) else { throw error } + attempt += 1 + try? await Task.sleep(for: delay) + delay *= 2 + } + } + } + + static func isRetryable(_ error: Error) -> Bool { + guard let apiError = error as? OutlineAPIError else { return false } + if case .transport = apiError { return true } + return false + } +} diff --git a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift index f0f3f67..8df1d93 100644 --- a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift @@ -553,4 +553,102 @@ final class CachingOutlineAPIClientTests: XCTestCase { // expected — nothing left under the old id } } + + // MARK: - Repeated-failure tracking + + /// A structural failure (decode/auth/server) isn't retried — it fails + /// the same way every time, so `RetryPolicy` gives up after one attempt + /// and it's logged immediately. No cache entry means no fallback either, + /// so every call rethrows. + func testRepeatedDecodingFailuresSurfaceAfterThreshold() async throws { + let stub = StubOutlineAPIClient() + stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + for _ in 0..<3 { + _ = try? await sut.documentInfo(id: "doc-1") + } + + let summaries = await sut.repeatedFailureSummaries() + XCTAssertEqual(summaries.count, 1) + XCTAssertEqual(summaries.first?.category, "document") + XCTAssertEqual(summaries.first?.count, 3) + } + + /// Two failures alone shouldn't trip the banner — only three or more + /// within the window counts as "repeated." + func testFewerThanThresholdFailuresDoNotSurface() async throws { + let stub = StubOutlineAPIClient() + stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + for _ in 0..<2 { + _ = try? await sut.documentInfo(id: "doc-1") + } + + let summaries = await sut.repeatedFailureSummaries() + XCTAssertTrue(summaries.isEmpty) + } + + /// A later success clears the category entirely — a transient run of + /// bad luck shouldn't leave a stale banner up after things recover. + func testSuccessAfterRepeatedFailuresClearsTheLog() async throws { + let stub = StubOutlineAPIClient() + let document = makeDocument() + var callCount = 0 + stub.documentInfoHandler = { _ in + callCount += 1 + if callCount <= 3 { throw OutlineAPIError.decoding(NotStubbed()) } + return document + } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + for _ in 0..<3 { + _ = try? await sut.documentInfo(id: "doc-1") + } + let beforeRecovery = await sut.repeatedFailureSummaries() + XCTAssertEqual(beforeRecovery.count, 1) + + _ = try await sut.documentInfo(id: "doc-1") + + let afterRecovery = await sut.repeatedFailureSummaries() + XCTAssertTrue(afterRecovery.isEmpty) + } + + /// Plain connectivity loss (`OutlineAPIError.transport`) already has its + /// own offline UI elsewhere — it shouldn't also pile up in the repeated- + /// failure log and pop a second, redundant banner. + func testTransportFailuresDoNotCountTowardTheRepeatedFailureLog() async throws { + let stub = StubOutlineAPIClient() + stub.documentInfoHandler = { _ in throw OutlineAPIError.transport(URLError(.notConnectedToInternet)) } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + _ = try? await sut.documentInfo(id: "doc-1") + + let summaries = await sut.repeatedFailureSummaries() + XCTAssertTrue(summaries.isEmpty) + } + + /// Different categories (document reads vs. pin writes) track + /// independently — a broken pins endpoint shouldn't mask, or be masked + /// by, unrelated document failures, and one crossing the threshold + /// shouldn't drag an unrelated one along with it. + func testFailuresInDifferentCategoriesDoNotMix() async throws { + let stub = StubOutlineAPIClient() + stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) } + stub.createPinHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + // Document reads cross the threshold... + for _ in 0..<3 { + _ = try? await sut.documentInfo(id: "doc-1") + } + // ...pin creates don't. + for _ in 0..<2 { + _ = try? await sut.createPin(CreatePinRequest(documentId: "doc-1", collectionId: nil)) + } + + let summaries = await sut.repeatedFailureSummaries() + XCTAssertEqual(summaries.map(\.category), ["document"]) + } } diff --git a/OutlineKit/Tests/OutlineKitTests/RetryPolicyTests.swift b/OutlineKit/Tests/OutlineKitTests/RetryPolicyTests.swift new file mode 100644 index 0000000..74fab28 --- /dev/null +++ b/OutlineKit/Tests/OutlineKitTests/RetryPolicyTests.swift @@ -0,0 +1,69 @@ +import XCTest +@testable import OutlineKit + +private struct PlainError: Error {} + +final class RetryPolicyTests: XCTestCase { + func testSucceedsOnFirstAttemptWithoutRetrying() async throws { + var callCount = 0 + let result = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) { + callCount += 1 + return "ok" + } + XCTAssertEqual(result, "ok") + XCTAssertEqual(callCount, 1) + } + + func testRetriesTransportErrorsAndSucceedsOnceItStopsFailing() async throws { + var callCount = 0 + let result = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) { () -> String in + callCount += 1 + if callCount < 3 { throw OutlineAPIError.transport(URLError(.timedOut)) } + return "ok" + } + XCTAssertEqual(result, "ok") + XCTAssertEqual(callCount, 3) + } + + func testGivesUpAfterMaxAttemptsAndRethrowsTheLastError() async throws { + var callCount = 0 + do { + _ = try await RetryPolicy.withRetry(maxAttempts: 3, initialDelay: .milliseconds(1)) { () -> String in + callCount += 1 + throw OutlineAPIError.transport(URLError(.timedOut)) + } + XCTFail("Expected the persistent failure to be rethrown") + } catch { + XCTAssertEqual(callCount, 3) + } + } + + /// A decode failure means the response is structurally wrong — trying + /// again gets the exact same wrong response, so it isn't worth the + /// cooldown window the way a network blip is. + func testDoesNotRetryNonTransportErrors() async throws { + var callCount = 0 + do { + _ = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) { () -> String in + callCount += 1 + throw OutlineAPIError.decoding(PlainError()) + } + XCTFail("Expected the decoding error to be rethrown without retrying") + } catch { + XCTAssertEqual(callCount, 1) + } + } + + func testDoesNotRetryErrorsThatAreNotOutlineAPIErrors() async throws { + var callCount = 0 + do { + _ = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) { () -> String in + callCount += 1 + throw PlainError() + } + XCTFail("Expected the error to be rethrown without retrying") + } catch { + XCTAssertEqual(callCount, 1) + } + } +} From 7369b46b87497a791bd51e2f7a584b88d0c24663 Mon Sep 17 00:00:00 2001 From: psmattas Date: Fri, 21 Aug 2026 00:45:58 +0100 Subject: [PATCH 03/12] feat(app): auto-retry every remaining try?-swallowed API call, add repeated-failure banner Second half of the silent-failure fix - OutlineKit's RetryPolicy and CachingOutlineAPIClient tracking landed in 512c6d2, this wires the rest of the app onto it. Every bare `try? await apiClient.X(...)` that bypasses CachingOutlineAPIClient's own caching (listPins, listSubscriptions, listViews, listStars, documentUsers, listUsers, listComments, currentUser, installationInfo, authInfo, deleteAttachment - the "pass-through" methods) now goes through RetryPolicy.withRetry first, so a single transient blip gets absorbed automatically instead of just returning nil. Calls that were already routed through CachingOutlineAPIClient's cached-read path (documentInfo, listDocuments, listCollections, etc.) are left alone - they picked up retry and repeated-failure tracking for free from the previous commit and wrapping them again would've just retried twice. New: APIFailureCenter (Root/) turns CachingOutlineAPIClient's repeatedFailureSummaries() into a banner - RootView polls it every 30s while signed in (cheap, no network call of its own) and shows RepeatedFailureBanner for whichever category is currently past the threshold. No manual "Retry" button - the retries already happened automatically before the banner ever appears, so the only actions are Report (opens a prefilled Gitea issue - category, generic error description, app/OS version, no document content or server URL) and dismiss, which starts a 15-minute cooldown so a still-flaky operation doesn't immediately pop the same banner back up. Not compiler-verified - the Outpost app target has no CLI build path, only OutlineKit does (92/92 passing as of the previous commit, no OutlineKit changes here). --- .../Account/SettingsSidebarList.swift | 2 +- Outpost/Features/Account/SettingsView.swift | 6 +- .../CollectionDocumentsOutline.swift | 2 +- .../Collections/DocumentCommentsSheet.swift | 2 +- .../Collections/DocumentReaderView.swift | 16 +++-- .../Collections/DocumentReaderViewModel.swift | 10 ++- .../Collections/DocumentShareSheet.swift | 4 +- Outpost/Features/Home/HomeViewModel.swift | 4 +- Outpost/Root/APIFailureCenter.swift | 66 +++++++++++++++++++ Outpost/Root/RepeatedFailureBanner.swift | 38 +++++++++++ Outpost/Root/RootView.swift | 60 +++++++++++++++++ Outpost/Root/SessionStore.swift | 2 +- Outpost/Support/StarStore.swift | 2 +- 13 files changed, 193 insertions(+), 21 deletions(-) create mode 100644 Outpost/Root/APIFailureCenter.swift create mode 100644 Outpost/Root/RepeatedFailureBanner.swift 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) } }) From 5d5cda9cea785fcd6afba3a1a5f3071eec4dd2fc Mon Sep 17 00:00:00 2001 From: psmattas Date: Fri, 21 Aug 2026 00:48:17 +0100 Subject: [PATCH 04/12] fix: RetryPolicy.withRetry closure label, missing Foundation import RetryPolicy.withRetry's operation param wasn't anonymous (_), so the 14 Outpost call sites that pass the closure in parens - RetryPolicy. withRetry({ ... }) - rather than as a trailing closure failed to compile ("Missing argument label 'operation:'" cascading into nonsense errors about maxAttempts). OutlineKit's own internal call sites all happened to use trailing-closure syntax, so this only showed up once the app target actually got compiled. One-line fix at the declaration (_ operation:) instead of touching every call site - trailing-closure calls are unaffected either way. APIFailureCenter.swift used Date/TimeInterval/URL/URLComponents/ URLQueryItem while only importing Observation and OutlineKit - missing import Foundation. Other new files in the same commit escaped this because they import SwiftUI, which re-exports Foundation transitively; this one didn't. OutlineKit: 92/92 still passing. --- OutlineKit/Sources/OutlineKit/Support/RetryPolicy.swift | 2 +- Outpost/Root/APIFailureCenter.swift | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/OutlineKit/Sources/OutlineKit/Support/RetryPolicy.swift b/OutlineKit/Sources/OutlineKit/Support/RetryPolicy.swift index dacbd9a..53fc090 100644 --- a/OutlineKit/Sources/OutlineKit/Support/RetryPolicy.swift +++ b/OutlineKit/Sources/OutlineKit/Support/RetryPolicy.swift @@ -14,7 +14,7 @@ public enum RetryPolicy { public static func withRetry( maxAttempts: Int = 3, initialDelay: Duration = .seconds(1), - operation: () async throws -> T + _ operation: () async throws -> T ) async throws -> T { var attempt = 1 var delay = initialDelay diff --git a/Outpost/Root/APIFailureCenter.swift b/Outpost/Root/APIFailureCenter.swift index 40970fa..76f1afd 100644 --- a/Outpost/Root/APIFailureCenter.swift +++ b/Outpost/Root/APIFailureCenter.swift @@ -1,3 +1,4 @@ +import Foundation import Observation import OutlineKit From 5b876d7085cee8e5cb8727c03461d15d28fc0e3c Mon Sep 17 00:00:00 2001 From: psmattas Date: Fri, 21 Aug 2026 00:50:39 +0100 Subject: [PATCH 05/12] chore: remove Keyboard Shortcuts menu item and window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No real app-specific shortcuts existed - the panel only listed Return/⌘,/⌘W/⌘Q (standard macOS conventions everyone already knows, and it didn't even include the app's actual shortcuts like ⌘K for Command Palette). Removed the menu item from AccountFooter's bottom menu, the Window scene that hosted it, and KeyboardShortcutsView itself plus WindowConfigurator.swift (disablesFullScreen() had no other caller once this was gone). --- Outpost/Features/Account/AccountFooter.swift | 5 --- .../Account/KeyboardShortcutsView.swift | 39 ------------------- Outpost/OutpostApp.swift | 8 ---- Outpost/Support/WindowConfigurator.swift | 38 ------------------ 4 files changed, 90 deletions(-) delete mode 100644 Outpost/Features/Account/KeyboardShortcutsView.swift delete mode 100644 Outpost/Support/WindowConfigurator.swift diff --git a/Outpost/Features/Account/AccountFooter.swift b/Outpost/Features/Account/AccountFooter.swift index 79722ee..e4c2033 100644 --- a/Outpost/Features/Account/AccountFooter.swift +++ b/Outpost/Features/Account/AccountFooter.swift @@ -10,7 +10,6 @@ struct AccountFooter: View { @Environment(SessionStore.self) private var session @Environment(AppNavigation.self) private var navigation @Environment(\.openURL) private var openURL - @Environment(\.openWindow) private var openWindow @AppStorage("outpost.appearance") private var appearance: AppAppearance = .system @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false @State private var isMenuPresented = false @@ -63,10 +62,6 @@ struct AccountFooter: View { private var menuContent: some View { VStack(alignment: .leading, spacing: 2) { - menuItem("Keyboard Shortcuts…") { openWindow(id: "keyboard-shortcuts") } - - Divider() - menuItem("Documentation") { openURL(repositoryURL) } if let apiDocumentationURL { menuItem("API Documentation") { openURL(apiDocumentationURL) } diff --git a/Outpost/Features/Account/KeyboardShortcutsView.swift b/Outpost/Features/Account/KeyboardShortcutsView.swift deleted file mode 100644 index 2e98c6c..0000000 --- a/Outpost/Features/Account/KeyboardShortcutsView.swift +++ /dev/null @@ -1,39 +0,0 @@ -#if os(macOS) -import SwiftUI - -struct KeyboardShortcutsView: View { - private struct Shortcut: Identifiable { - let id = UUID() - let action: String - let keys: String - } - - private let shortcuts: [Shortcut] = [ - Shortcut(action: "Sign In", keys: "⏎"), - Shortcut(action: "Preferences", keys: "⌘ ,"), - Shortcut(action: "Close Window", keys: "⌘ W"), - Shortcut(action: "Quit Outpost", keys: "⌘ Q") - ] - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text("Keyboard Shortcuts") - .font(.title3.bold()) - - VStack(spacing: 10) { - ForEach(shortcuts) { shortcut in - HStack { - Text(shortcut.action) - Spacer() - Text(shortcut.keys) - .foregroundStyle(.secondary) - .monospaced() - } - } - } - } - .padding(24) - .frame(width: 280) - } -} -#endif diff --git a/Outpost/OutpostApp.swift b/Outpost/OutpostApp.swift index 3ad9c4a..c784767 100644 --- a/Outpost/OutpostApp.swift +++ b/Outpost/OutpostApp.swift @@ -75,14 +75,6 @@ struct OutpostApp: App { } } #endif - - #if os(macOS) - Window("Keyboard Shortcuts", id: "keyboard-shortcuts") { - KeyboardShortcutsView() - .disablesFullScreen() - } - .windowResizability(.contentSize) - #endif } #if os(macOS) diff --git a/Outpost/Support/WindowConfigurator.swift b/Outpost/Support/WindowConfigurator.swift deleted file mode 100644 index 412325a..0000000 --- a/Outpost/Support/WindowConfigurator.swift +++ /dev/null @@ -1,38 +0,0 @@ -#if os(macOS) -import SwiftUI -import AppKit - -/// Grabs the hosting `NSWindow` once it's attached to a screen, for the -/// handful of things SwiftUI's `Window` scene doesn't expose a modifier for. -private struct WindowConfigurator: NSViewRepresentable { - let configure: (NSWindow) -> Void - - func makeNSView(context: Context) -> NSView { - let view = NSView() - DispatchQueue.main.async { - if let window = view.window { - configure(window) - } - } - return view - } - - func updateNSView(_ nsView: NSView, context: Context) {} -} - -extension View { - /// `Window` scenes default to a full standard titlebar, fullscreen - /// (green) button included — not appropriate for fixed-size reference - /// panels like About or Keyboard Shortcuts, which have no reason to - /// support fullscreen at all. - func disablesFullScreen() -> some View { - background( - WindowConfigurator { window in - window.collectionBehavior.remove(.fullScreenPrimary) - window.collectionBehavior.insert(.fullScreenNone) - window.standardWindowButton(.zoomButton)?.isHidden = true - } - ) - } -} -#endif From 1580510cfb9597629c7569a8b0bd344ab10c4c0a Mon Sep 17 00:00:00 2001 From: psmattas Date: Fri, 21 Aug 2026 00:56:37 +0100 Subject: [PATCH 06/12] fix(engine): silence PERF logs by default, fix "modifying state during view update" PerfTrace was opt-out (MD_PERF=0 to silence) so every Debug build printed a PERF line per keystroke unconditionally. Flipped to opt-in (MD_PERF=1 to enable) - still fully available for future perf work, just quiet by default. "Modifying state during view update": onCodeBlockSelectionChange, onSelectedTextChange, and onCommentAnchorRectsChange could all fire synchronously from inside NativeTextViewWrapper.updateNSView's own call stack - a programmatic edit (pendingInlineReplacement/ pendingTextInsertion/pendingTextRangeReplacement) re-enters textViewDidChangeSelection/textDidChange synchronously (AppKit resets selection on edit), which is still a SwiftUI view update in progress. Calling straight into the embedder's @State setter there is exactly what trips the warning. Routed all three through new fireCodeBlockSelectionChange/fireSelectedTextChange/ fireCommentAnchorRectsChange helpers on the coordinator that defer one runloop tick via DispatchQueue.main.async - same technique NativeTextViewWrapper already uses to clear its own pending* bindings, just centralized instead of ad-hoc per call site. 322/322 tests passing. --- .../Diagnostics/PerfTrace.swift | 6 ++++-- ...NativeTextViewCoordinator+CodeBlocks.swift | 6 +++--- ...veTextViewCoordinator+CommentAnchors.swift | 4 ++-- ...tiveTextViewCoordinator+TextDelegate.swift | 2 +- .../NativeTextViewCoordinator.swift | 20 +++++++++++++++++++ 5 files changed, 30 insertions(+), 8 deletions(-) diff --git a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/Diagnostics/PerfTrace.swift b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/Diagnostics/PerfTrace.swift index 31dae22..527e2e1 100644 --- a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/Diagnostics/PerfTrace.swift +++ b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/Diagnostics/PerfTrace.swift @@ -9,7 +9,9 @@ // which costs grow with file size instead of staying constant. The whole point: // type in a short file, then a long one, and compare `total` for the same edit. // -// Toggle: set the env var MD_PERF=0 in the run scheme to silence. +// Toggle: set the env var MD_PERF=1 in the run scheme to enable. +// Off by default even in Debug — opt-in, not opt-out, so a normal +// debug run stays quiet. // Debug-only — the whole thing compiles out in Release. // Remove before shipping (this file + the `PerfTrace.` call sites). // @@ -18,7 +20,7 @@ import Foundation enum PerfTrace { #if DEBUG - static var enabled = ProcessInfo.processInfo.environment["MD_PERF"] != "0" + static var enabled = ProcessInfo.processInfo.environment["MD_PERF"] == "1" /// Opt-in for the sampled full-rebuild verifier asserts (wiki splice, /// backtick census, parse buffer). They run 3× O(doc) work synchronously /// on every 64th keystroke — periodic spikes that pollute the PERF diff --git a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift index 01e38f9..707d5c4 100644 --- a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift +++ b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CodeBlocks.swift @@ -15,7 +15,7 @@ import AppKit extension NativeTextViewCoordinator { func updateCodeBlockSelection(textView: NSTextView, parsed: ParsedDocument? = nil) { guard let textContainer = textView.textContainer else { - onCodeBlockSelectionChange?([]) + fireCodeBlockSelectionChange([]) return } @@ -24,7 +24,7 @@ extension NativeTextViewCoordinator { // no per-call full-token filter. cachedCodeBlockTokens = parsed.codeBlockTokensWithIndices } else if cachedCodeBlockTokens.isEmpty { - onCodeBlockSelectionChange?([]) + fireCodeBlockSelectionChange([]) return } @@ -91,6 +91,6 @@ extension NativeTextViewCoordinator { ) } - onCodeBlockSelectionChange?(selections) + fireCodeBlockSelectionChange(selections) } } diff --git a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CommentAnchors.swift b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CommentAnchors.swift index ffa133e..5b36ad5 100644 --- a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CommentAnchors.swift +++ b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+CommentAnchors.swift @@ -16,7 +16,7 @@ import AppKit extension NativeTextViewCoordinator { func updateCommentAnchorRects(textView: NSTextView) { guard !commentAnchorQueries.isEmpty else { - onCommentAnchorRectsChange?([]) + fireCommentAnchorRectsChange([]) return } let nsText = textView.string as NSString @@ -28,6 +28,6 @@ extension NativeTextViewCoordinator { let rect = textView.viewRect(forCharacterRange: found, using: layoutBridge) else { continue } results.append(CommentAnchorRect(id: query.id, rect: rect)) } - onCommentAnchorRectsChange?(results) + fireCommentAnchorRectsChange(results) } } diff --git a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift index b603db5..b8fd178 100644 --- a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift +++ b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator+TextDelegate.swift @@ -362,7 +362,7 @@ extension NativeTextViewCoordinator { // selection at all. if !isRebuildingDocument { let selRange = tv.selectedRange() - onSelectedTextChange?(selRange.length > 0 ? (tv.string as NSString).substring(with: selRange) : nil) + fireSelectedTextChange(selRange.length > 0 ? (tv.string as NSString).substring(with: selRange) : nil) } // Raw mode: plain source — no reveal, snap-back, or inline previews. if configuration.rawSourceMode { return } diff --git a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift index e7c9471..2830553 100644 --- a/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift +++ b/Vendor/swift-markdown-engine/Sources/MarkdownEngine/TextView/Coordinator/NativeTextViewCoordinator.swift @@ -87,6 +87,26 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate { var onSelectedTextChange: ((String?) -> Void)? var commentAnchorQueries: [CommentAnchorQuery] = [] var onCommentAnchorRectsChange: (([CommentAnchorRect]) -> Void)? + + /// These three callbacks can fire from inside `NativeTextViewWrapper.updateNSView` + /// itself (a programmatic edit re-enters `textViewDidChangeSelection`/ + /// `textDidChange` synchronously — see `isRebuildingDocument` above), which is a + /// SwiftUI view update already in progress on the call stack. Calling straight into + /// an embedder's `@State` setter there trips "Modifying state during view update" — + /// deferring one runloop tick is enough to land outside it, same as how + /// `NativeTextViewWrapper` already clears its `pending*` bindings. + func fireCodeBlockSelectionChange(_ selections: [CodeBlockSelection]) { + DispatchQueue.main.async { [onCodeBlockSelectionChange] in onCodeBlockSelectionChange?(selections) } + } + + func fireSelectedTextChange(_ text: String?) { + DispatchQueue.main.async { [onSelectedTextChange] in onSelectedTextChange?(text) } + } + + func fireCommentAnchorRectsChange(_ rects: [CommentAnchorRect]) { + DispatchQueue.main.async { [onCommentAnchorRectsChange] in onCommentAnchorRectsChange?(rects) } + } + var didInitialFormatting: Bool = false /// One-shot guard so `updateCodeBlockSelection` only forces a full-document layout once per document. var didEnsureLayoutForCurrentDocument: Bool = false From 20baab78c05bbc5e5aedcb50298171ceebfa722a Mon Sep 17 00:00:00 2001 From: psmattas Date: Fri, 21 Aug 2026 01:15:15 +0100 Subject: [PATCH 07/12] feat(outlinekit): encrypt the offline cache at rest Both CachedPayload and PendingOperation only ever stored their payload as plain JSON on disk (SwiftData/SQLite, no encryption of its own) - readable by anyone with access to the logged-in session, per the earlier discussion on where this cache lives. Adds AES-GCM encryption at the one place raw bytes cross into/out of OfflineCacheStore (CachingOutlineAPIClient, which already owns encode/decode) - OfflineCacheStore itself stays a dumb opaque-blob store, since its key/id/kind columns can't be encrypted without breaking the #Predicate queries built against them. Key management (KeychainCacheEncryptionKeyStore, mirrors KeychainTokenStore exactly): a random 256-bit key, generated once and Keychain-stored, not derived from anything guessable. It doesn't need deriving to survive an uninstall/reinstall either - Keychain items are scoped to the app's code signature, not its on-disk presence, so a reinstall of the same app regains access to the same key automatically (same reason a saved API token already survives a reinstall today). If the on-disk cache also happens to survive (dragging the .app to the Trash doesn't clean ~/Library/Containers), a reinstall can still read it. A row written before this shipped (still plaintext) or encrypted under a since-cleared key just fails to decrypt and is treated as a cache miss - same as any other decode failure, so it silently refetches and re-caches encrypted rather than crashing. No explicit migration needed. Also: OfflineCacheStore.clearEverything() wipes both the cache AND the pending write queue (clearAll(), used by Settings' "Clear All Cache", still only touches the cache - it shouldn't silently discard someone's unsynced edits). Exposed as CachingOutlineAPIClient.clearEverythingForSignOut(), for sign-out to use alongside clearing the key. CacheEncryptionKeyStoring is a protocol (like TokenStoring) so tests never touch the real Keychain - existing CachingOutlineAPIClientTests now inject an in-memory StaticCacheEncryptionKeyStore. 8 new tests (retry/failure-log tests from the previous commit plus 3 new ones here: ciphertext isn't plaintext JSON, a cleared key makes old rows unreadable, clearEverythingForSignOut wipes both tables). 95/95 passing. --- .../Caching/CacheEncryptionKeyStoring.swift | 16 +++ .../Caching/CachePayloadCryptor.swift | 24 ++++ .../Caching/CachingOutlineAPIClient.swift | 101 ++++++++++--- .../KeychainCacheEncryptionKeyStore.swift | 76 ++++++++++ .../Caching/OfflineCacheStore.swift | 16 +++ .../CachingOutlineAPIClientTests.swift | 134 ++++++++++++++---- 6 files changed, 320 insertions(+), 47 deletions(-) create mode 100644 OutlineKit/Sources/OutlineKit/Caching/CacheEncryptionKeyStoring.swift create mode 100644 OutlineKit/Sources/OutlineKit/Caching/CachePayloadCryptor.swift create mode 100644 OutlineKit/Sources/OutlineKit/Caching/KeychainCacheEncryptionKeyStore.swift diff --git a/OutlineKit/Sources/OutlineKit/Caching/CacheEncryptionKeyStoring.swift b/OutlineKit/Sources/OutlineKit/Caching/CacheEncryptionKeyStoring.swift new file mode 100644 index 0000000..c1fdf4f --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Caching/CacheEncryptionKeyStoring.swift @@ -0,0 +1,16 @@ +import Foundation +import CryptoKit + +/// Boundary over wherever the offline cache's symmetric encryption key +/// lives. Mirrors `TokenStoring` — same reasoning, different secret. +public protocol CacheEncryptionKeyStoring: Sendable { + /// Returns the existing key, generating and persisting a new random one + /// on first use if none exists yet. + func key() throws -> SymmetricKey + /// Called on sign-out. Anything still encrypted with the cleared key + /// becomes permanently unreadable — that's the point, not a bug: the + /// next person signed in on this machine shouldn't be able to read a + /// previous account's cached content just because the on-disk rows + /// happen to still be there. + func clear() throws +} diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachePayloadCryptor.swift b/OutlineKit/Sources/OutlineKit/Caching/CachePayloadCryptor.swift new file mode 100644 index 0000000..81336ed --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Caching/CachePayloadCryptor.swift @@ -0,0 +1,24 @@ +import Foundation +import CryptoKit + +/// AES-GCM at the boundary where `CachingOutlineAPIClient` writes to/reads +/// from `OfflineCacheStore`. Encryption lives at this layer rather than +/// inside `OfflineCacheStore` itself — that stays a dumb opaque-blob store; +/// its `key`/`id`/`kind` columns can't be encrypted without breaking the +/// `#Predicate` queries built directly against them. +enum CachePayloadCryptor { + enum CryptoError: Error { + case sealingFailed + } + + static func encrypt(_ data: Data, key: SymmetricKey) throws -> Data { + let sealedBox = try AES.GCM.seal(data, using: key) + guard let combined = sealedBox.combined else { throw CryptoError.sealingFailed } + return combined + } + + static func decrypt(_ data: Data, key: SymmetricKey) throws -> Data { + let sealedBox = try AES.GCM.SealedBox(combined: data) + return try AES.GCM.open(sealedBox, using: key) + } +} diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift index 0342b49..eaadd97 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift @@ -1,4 +1,5 @@ import Foundation +import CryptoKit /// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with offline /// support at the existing protocol boundary, so no view model needs to know @@ -36,6 +37,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { private let encoder: JSONEncoder private let decoder: JSONDecoder private let keyEncoder: JSONEncoder + private let encryptionKeyStore: CacheEncryptionKeyStoring + /// Resolved lazily and kept for this instance's lifetime — a fresh + /// instance is created on every sign-in/sign-out anyway (see + /// `SessionStore.makeAPIClient`), so there's no staleness risk, just + /// one fewer Keychain round-trip per cache read/write. + private var cachedEncryptionKey: SymmetricKey? /// Recent failure timestamps per category — see `recordFailure` / /// `repeatedFailureSummaries()`. A category only shows up there once it's @@ -47,10 +54,16 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { private let failureWindow: TimeInterval = 300 private let failureThreshold: Int = 3 - public init(live: OutlineAPIClient, cache: OfflineCacheStore, defaults: UserDefaults = .standard) { + public init( + live: OutlineAPIClient, + cache: OfflineCacheStore, + defaults: UserDefaults = .standard, + encryptionKeyStore: CacheEncryptionKeyStoring = KeychainCacheEncryptionKeyStore() + ) { self.live = live self.cache = cache self.defaults = defaults + self.encryptionKeyStore = encryptionKeyStore let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 @@ -495,6 +508,15 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { await cache.clearAll() } + /// Sign-out only — see `OfflineCacheStore.clearEverything()` and + /// `CacheEncryptionKeyStoring.clear()`. Callers must clear the + /// encryption key too (this actor doesn't own that decision); wiping + /// the storage here without it would leave the key to be reused by + /// whoever signs in next. + public func clearEverythingForSignOut() async { + await cache.clearEverything() + } + /// Replays every queued operation against `live`, in the order they were /// queued. Each is independent — one failing doesn't block the rest. public func flushPendingOperations() async -> SyncFlushSummary { @@ -603,13 +625,13 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { /// SwiftData read, no network involved. public func cachedDocumentsIndex() async -> [OutlineDocument] { let payloads = await cache.loadAll(keyPrefix: "document:") - return payloads.compactMap { try? decoder.decode(OutlineDocument.self, from: $0) } + return payloads.compactMap { decryptedDecode(OutlineDocument.self, from: $0) } } /// Every individually cached collection from the last Full Local Sync. public func cachedCollectionsIndex() async -> [OutlineCollection] { let payloads = await cache.loadAll(keyPrefix: "collection:") - return payloads.compactMap { try? decoder.decode(OutlineCollection.self, from: $0) } + return payloads.compactMap { decryptedDecode(OutlineCollection.self, from: $0) } } // MARK: - Helpers @@ -621,7 +643,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { // the device actually had a connection, defeating the point of // deliberately testing/working as if offline. if isManualOfflineModeEnabled { - if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) { + if let data = await cache.load(forKey: key), let cached = decryptedDecode(T.self, from: data) { return cached } throw OutlineAPIError.transport(URLError(.notConnectedToInternet)) @@ -629,7 +651,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { do { let result = try await RetryPolicy.withRetry { try await fetch() } recordSuccess(category: category) - if let data = try? encoder.encode(result) { + if let data = encryptedEncode(result) { await cache.save(data, forKey: key) } return result @@ -642,13 +664,48 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { if !RetryPolicy.isRetryable(error) { recordFailure(category: category, message: errorDescription(error)) } - if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) { + if let data = await cache.load(forKey: key), let cached = decryptedDecode(T.self, from: data) { return cached } throw error } } + private func resolvedEncryptionKey() throws -> SymmetricKey { + if let cachedEncryptionKey { return cachedEncryptionKey } + let key = try encryptionKeyStore.key() + cachedEncryptionKey = key + return key + } + + /// Encrypts before it ever reaches SwiftData. `nil` on any failure + /// (matches the shape of the plain `try? encoder.encode(...)` this + /// replaces) — a Keychain hiccup here should behave exactly like an + /// encode failure already did: skip caching this one value, not crash. + private func encryptedEncode(_ value: some Encodable) -> Data? { + guard let plain = try? encoder.encode(value), let key = try? resolvedEncryptionKey() else { return nil } + return try? CachePayloadCryptor.encrypt(plain, key: key) + } + + /// Decrypts + decodes a value previously written by `encryptedEncode`. + /// A row written before this feature shipped (still plaintext JSON, or + /// anything encrypted under a key that's since been cleared by + /// sign-out) fails to decrypt and returns `nil` here — same as any + /// other decode failure, so `cachedFetch` treats it as a cache miss and + /// refetches, not a crash. + private func decryptedDecode(_ type: T.Type, from data: Data) -> T? { + guard let key = try? resolvedEncryptionKey(), let plain = try? CachePayloadCryptor.decrypt(data, key: key) else { return nil } + return try? decoder.decode(type, from: plain) + } + + /// Throwing counterpart for `replay(_:)`, where a genuine decode + /// failure needs to propagate (so `flushPendingOperations` records it + /// as a failed sync attempt) instead of silently vanishing. + private func decryptedDecodeThrowing(_ type: T.Type, from data: Data) throws -> T { + let plain = try CachePayloadCryptor.decrypt(data, key: try resolvedEncryptionKey()) + return try decoder.decode(type, from: plain) + } + private func requestKey(_ prefix: String, _ request: some Encodable) -> String { guard let data = try? keyEncoder.encode(request), let json = String(data: data, encoding: .utf8) else { return prefix @@ -657,19 +714,19 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { } private func cacheDocument(_ document: OutlineDocument) async { - if let data = try? encoder.encode(document) { + if let data = encryptedEncode(document) { await cache.save(data, forKey: "document:\(document.id)") } } private func cacheCollection(_ collection: OutlineCollection) async { - if let data = try? encoder.encode(collection) { + if let data = encryptedEncode(collection) { await cache.save(data, forKey: "collection:\(collection.id)") } } private func enqueue(_ kind: PendingOperationKind, payload: some Encodable, id: String) async { - guard let data = try? encoder.encode(payload) else { return } + guard let data = encryptedEncode(payload) else { return } await cache.enqueueOperation(id: id, kind: kind.rawValue, payload: data) } @@ -708,7 +765,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument { guard let baseData = await cache.load(forKey: "document:\(request.id)"), - let base = try? decoder.decode(OutlineDocument.self, from: baseData) else { + let base = decryptedDecode(OutlineDocument.self, from: baseData) else { throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet)) } let mergedText = request.append == true ? base.text + (request.text ?? "") : (request.text ?? base.text) @@ -737,7 +794,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { // of it and the update would just fail every retry. if merged.id.hasPrefix("pending-"), let createOp = await cache.pendingOperations().first(where: { $0.id == merged.id && $0.kind == PendingOperationKind.createDocument.rawValue }), - let createRequest = try? decoder.decode(CreateDocumentRequest.self, from: createOp.payload) { + let createRequest = decryptedDecode(CreateDocumentRequest.self, from: createOp.payload) { let resolvedCreate = CreateDocumentRequest( title: merged.title, text: merged.text, @@ -759,7 +816,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { private func queueCollectionUpdate(_ request: UpdateCollectionRequest, dueTo error: Error?) async throws -> OutlineCollection { guard let baseData = await cache.load(forKey: "collection:\(request.id)"), - let base = try? decoder.decode(OutlineCollection.self, from: baseData) else { + let base = decryptedDecode(OutlineCollection.self, from: baseData) else { throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet)) } let merged = OutlineCollection( @@ -809,40 +866,40 @@ public actor CachingOutlineAPIClient: OutlineAPIClient { } switch kind { case .createDocument: - let request = try decoder.decode(CreateDocumentRequest.self, from: operation.payload) + let request = try decryptedDecodeThrowing(CreateDocumentRequest.self, from: operation.payload) let result = try await live.createDocument(request) await cacheDocument(result) // The placeholder id (== operation.id) is now a dead orphan — // nothing server-side will ever answer to it again. await cache.removeCacheEntry(forKey: "document:\(operation.id)") case .updateDocument: - let request = try decoder.decode(UpdateDocumentRequest.self, from: operation.payload) + let request = try decryptedDecodeThrowing(UpdateDocumentRequest.self, from: operation.payload) let result = try await live.updateDocument(request) await cacheDocument(result) case .updateCollection: - let request = try decoder.decode(UpdateCollectionRequest.self, from: operation.payload) + let request = try decryptedDecodeThrowing(UpdateCollectionRequest.self, from: operation.payload) let result = try await live.updateCollection(request) await cacheCollection(result) case .createPin: - let request = try decoder.decode(CreatePinRequest.self, from: operation.payload) + let request = try decryptedDecodeThrowing(CreatePinRequest.self, from: operation.payload) _ = try await live.createPin(request) case .deletePin: - let request = try decoder.decode(IDPayload.self, from: operation.payload) + let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload) try await live.deletePin(id: request.id) case .createSubscription: - let request = try decoder.decode(CreateSubscriptionRequest.self, from: operation.payload) + let request = try decryptedDecodeThrowing(CreateSubscriptionRequest.self, from: operation.payload) _ = try await live.createSubscription(request) case .deleteSubscription: - let request = try decoder.decode(IDPayload.self, from: operation.payload) + let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload) try await live.deleteSubscription(id: request.id) case .starDocument: - let request = try decoder.decode(StarDocumentRequest.self, from: operation.payload) + let request = try decryptedDecodeThrowing(StarDocumentRequest.self, from: operation.payload) _ = try await live.starDocument(request) case .deleteStar: - let request = try decoder.decode(IDPayload.self, from: operation.payload) + let request = try decryptedDecodeThrowing(IDPayload.self, from: operation.payload) try await live.deleteStar(id: request.id) case .starCollection: - let request = try decoder.decode(StarCollectionRequest.self, from: operation.payload) + let request = try decryptedDecodeThrowing(StarCollectionRequest.self, from: operation.payload) _ = try await live.starCollection(request) } } diff --git a/OutlineKit/Sources/OutlineKit/Caching/KeychainCacheEncryptionKeyStore.swift b/OutlineKit/Sources/OutlineKit/Caching/KeychainCacheEncryptionKeyStore.swift new file mode 100644 index 0000000..029329a --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Caching/KeychainCacheEncryptionKeyStore.swift @@ -0,0 +1,76 @@ +import Foundation +import CryptoKit +import Security + +/// Real Keychain-backed `CacheEncryptionKeyStoring`. The key is a plain +/// random 256-bit value — deliberately NOT derived from anything guessable +/// (bundle id, device id, etc.). It doesn't need deriving to survive an +/// uninstall/reinstall of the app either: Keychain items are scoped to the +/// requesting app's code signature (bundle id + team id), not its on-disk +/// presence, so reinstalling the same app regains access to the same +/// Keychain item automatically — exactly how `KeychainTokenStore`'s saved +/// token already survives a reinstall today. If the on-disk cache also +/// happens to survive (macOS doesn't clean out `~/Library/Containers` just +/// because the .app bundle was dragged to the Trash), the reinstalled app +/// can still decrypt it; a different app, or the same app after an explicit +/// sign-out (`clear()`), can't. +public final class KeychainCacheEncryptionKeyStore: CacheEncryptionKeyStoring, @unchecked Sendable { + private let service: String + private let account: String + + public init(service: String = "com.outpost.outlinekit", account: String = "offline-cache-encryption-key") { + self.service = service + self.account = account + } + + public func key() throws -> SymmetricKey { + if let existing = try? readKey() { return existing } + let generated = SymmetricKey(size: .bits256) + try store(generated) + return generated + } + + public func clear() throws { + let status = SecItemDelete(baseQuery() as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw TokenStoreError.deleteFailed(status) + } + } + + private func readKey() throws -> SymmetricKey { + var query = baseQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { + throw TokenStoreError.notFound + } + return SymmetricKey(data: data) + } + + private func store(_ key: SymmetricKey) throws { + let data = key.withUnsafeBytes { Data($0) } + let query = baseQuery() + + let existsStatus = SecItemCopyMatching(query as CFDictionary, nil) + if existsStatus == errSecSuccess { + let updateStatus = SecItemUpdate(query as CFDictionary, [kSecValueData as String: data] as CFDictionary) + guard updateStatus == errSecSuccess else { throw TokenStoreError.storeFailed(updateStatus) } + } else { + var addQuery = query + addQuery[kSecValueData as String] = data + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + guard addStatus == errSecSuccess else { throw TokenStoreError.storeFailed(addStatus) } + } + } + + private func baseQuery() -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + } +} diff --git a/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift b/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift index 71ec96c..0823045 100644 --- a/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift +++ b/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift @@ -67,6 +67,22 @@ public actor OfflineCacheStore { try? modelContext.save() } + /// Wipes both the read-through cache AND the pending write queue — + /// used only on sign-out (see `SessionStore.signOut()`), since the + /// encryption key backing every row here is about to be cleared too. + /// Anything left un-wiped would just become permanently undecryptable + /// garbage instead of readable by the next account signed in on this + /// machine — deliberately more thorough than `clearAll()` (Settings' + /// "Clear All Cache", which never touches pending writes; that button + /// shouldn't silently discard someone's unsynced edits). + public func clearEverything() { + let cachedDescriptor = FetchDescriptor() + (try? modelContext.fetch(cachedDescriptor))?.forEach { modelContext.delete($0) } + let pendingDescriptor = FetchDescriptor() + (try? modelContext.fetch(pendingDescriptor))?.forEach { modelContext.delete($0) } + try? modelContext.save() + } + // MARK: - Offline write queue /// Upserts by `id` — a second call with the same id (an edit coalescing diff --git a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift index 8df1d93..f6b9471 100644 --- a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift +++ b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift @@ -1,4 +1,5 @@ import XCTest +import CryptoKit @testable import OutlineKit private struct NotStubbed: Error {} @@ -116,6 +117,22 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable private struct StubTransportError: Error {} +/// In-memory `CacheEncryptionKeyStoring` — tests must never touch the real +/// Keychain (would pollute the developer's machine and can hang/fail in a +/// sandboxed CI runner with no Keychain access). +private final class StaticCacheEncryptionKeyStore: CacheEncryptionKeyStoring, @unchecked Sendable { + private var stored: SymmetricKey? + + func key() throws -> SymmetricKey { + if let stored { return stored } + let generated = SymmetricKey(size: .bits256) + stored = generated + return generated + } + + func clear() throws { stored = nil } +} + final class CachingOutlineAPIClientTests: XCTestCase { private func makeCache() throws -> OfflineCacheStore { OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true)) @@ -145,7 +162,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { let stub = StubOutlineAPIClient() let document = makeDocument() stub.documentInfoHandler = { _ in document } - let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore()) let result = try await sut.documentInfo(id: "doc-1") @@ -161,7 +178,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { if callCount == 1 { return document } throw StubTransportError() } - let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore()) // First call succeeds and populates the cache. _ = try await sut.documentInfo(id: "doc-1") @@ -175,7 +192,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { func testDocumentInfoRethrowsWhenLiveFailsAndCacheIsEmpty() async throws { let stub = StubOutlineAPIClient() stub.documentInfoHandler = { _ in throw StubTransportError() } - let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore()) do { _ = try await sut.documentInfo(id: "doc-1") @@ -194,7 +211,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { if callCount == 1 { return collections } throw StubTransportError() } - let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore()) _ = try await sut.listCollections(offset: 0, limit: 25) let result = try await sut.listCollections(offset: 0, limit: 25) @@ -207,7 +224,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { let docOne = makeDocument(id: "doc-1", title: "One") let docTwo = makeDocument(id: "doc-2", title: "Two") stub.documentInfoHandler = { id in id == "doc-1" ? docOne : docTwo } - let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore()) _ = try await sut.documentInfo(id: "doc-1") _ = try await sut.documentInfo(id: "doc-2") @@ -227,7 +244,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { let original = makeDocument(id: "doc-1", title: "Original") stub.documentInfoHandler = { _ in original } stub.updateDocumentHandler = { _ in throw StubTransportError() } - let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore()) // Populate the cache with the base document first (as a real open would). _ = try await sut.documentInfo(id: "doc-1") @@ -248,7 +265,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { func testUpdateDocumentRethrowsWhenDocumentWasNeverCached() async throws { let stub = StubOutlineAPIClient() stub.updateDocumentHandler = { _ in throw StubTransportError() } - let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore()) do { _ = try await sut.updateDocument(UpdateDocumentRequest(id: "never-seen", title: "x")) @@ -263,7 +280,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { let original = makeDocument(id: "doc-1", title: "Original") stub.documentInfoHandler = { _ in original } stub.updateDocumentHandler = { _ in throw StubTransportError() } - let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore()) _ = try await sut.documentInfo(id: "doc-1") _ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "First Edit")) @@ -276,7 +293,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { func testPinThenUnpinBeforeSyncCancelsOutWithoutQueuingADelete() async throws { let stub = StubOutlineAPIClient() stub.createPinHandler = { _ in throw StubTransportError() } - let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore()) let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1")) XCTAssertTrue(pin.id.hasPrefix("pending-")) @@ -294,7 +311,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { let original = makeDocument(id: "doc-1", title: "Original") stub.documentInfoHandler = { _ in original } stub.updateDocumentHandler = { _ in throw StubTransportError() } - let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore()) _ = try await sut.documentInfo(id: "doc-1") _ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline")) @@ -318,7 +335,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { let original = makeDocument(id: "doc-1", title: "Original") stub.documentInfoHandler = { _ in original } stub.updateDocumentHandler = { _ in throw StubTransportError() } - let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore()) _ = try await sut.documentInfo(id: "doc-1") _ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline")) @@ -345,7 +362,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { liveCallCount += 1 return OutlinePin(id: "real-id", documentId: "doc-1", collectionId: nil, index: nil) } - let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), defaults: defaults) + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), defaults: defaults, encryptionKeyStore: StaticCacheEncryptionKeyStore()) let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1")) @@ -370,7 +387,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { return [cachedCollection] } let cache = try makeCache() - let sut = CachingOutlineAPIClient(live: stub, cache: cache, defaults: defaults) + let sut = CachingOutlineAPIClient(live: stub, cache: cache, defaults: defaults, encryptionKeyStore: StaticCacheEncryptionKeyStore()) // Online first — populates the cache normally. let firstResult = try await sut.listCollections(offset: 0, limit: 25) @@ -389,7 +406,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { func testCacheStorageSummaryReflectsCachedItems() async throws { let stub = StubOutlineAPIClient() stub.documentInfoHandler = { _ in self.makeDocument() } - let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore()) _ = try await sut.documentInfo(id: "doc-1") let summary = await sut.cacheStorageSummary() @@ -418,7 +435,7 @@ final class CachingOutlineAPIClientTests: XCTestCase { return (0.. Date: Fri, 21 Aug 2026 01:15:26 +0100 Subject: [PATCH 08/12] feat(app): clear the cache encryption key on sign-out, note it in Settings SessionStore.signOut() now clears the offline cache's Keychain-stored encryption key alongside the API token, and wipes the cache/pending- write storage itself (CachingOutlineAPIClient.clearEverythingForSignOut()) before doing so - so a previous account's cached content isn't sitting there readable (even in principle, if the on-disk rows survive) by whoever signs in next on the same machine. signOut() is async now to do this properly instead of firing a detached Task; both call sites (the logout confirmation dialog, delete-account) updated. Settings -> Offline & Sync now states plainly that the local cache is encrypted at rest and cleared on log out - not compiler-verified (Outpost app target has no CLI build path), worth a look in Xcode. --- Outpost/Features/Account/SettingsView.swift | 7 +++- Outpost/Root/SessionStore.swift | 37 ++++++++++++++++--- Outpost/Support/View+LogoutConfirmation.swift | 2 +- 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/Outpost/Features/Account/SettingsView.swift b/Outpost/Features/Account/SettingsView.swift index 6213f0c..b1a62fb 100644 --- a/Outpost/Features/Account/SettingsView.swift +++ b/Outpost/Features/Account/SettingsView.swift @@ -618,7 +618,7 @@ struct SettingsView: View { defer { isDeletingAccount = false } do { try await apiClient.deleteAccount() - session.signOut() + await session.signOut() } catch { deleteAccountErrorMessage = outlineErrorMessage(error, fallback: "Couldn't delete your account.") } @@ -1182,6 +1182,11 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: 20) { sectionHeader + Label("Everything cached here is encrypted at rest with a key stored in Keychain, cleared automatically when you log out.", systemImage: "lock.fill") + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: 480, alignment: .leading) + VStack(alignment: .leading, spacing: 6) { Toggle("Offline Mode", isOn: $isOfflineModeEnabled) Text("Skip the network entirely and work from what's already been cached. Turn this off to reconnect.") diff --git a/Outpost/Root/SessionStore.swift b/Outpost/Root/SessionStore.swift index fa14818..6f6277a 100644 --- a/Outpost/Root/SessionStore.swift +++ b/Outpost/Root/SessionStore.swift @@ -15,6 +15,7 @@ final class SessionStore { private static let userPreferencesDefaultsKey = "outline.userPreferences" private let tokenStore: TokenStoring + private let cacheEncryptionKeyStore: CacheEncryptionKeyStoring private let defaults: UserDefaults var isSignedIn: Bool @@ -43,8 +44,13 @@ final class SessionStore { defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:)) } - init(tokenStore: TokenStoring = KeychainTokenStore(), defaults: UserDefaults = .standard) { + init( + tokenStore: TokenStoring = KeychainTokenStore(), + cacheEncryptionKeyStore: CacheEncryptionKeyStoring = KeychainCacheEncryptionKeyStore(), + defaults: UserDefaults = .standard + ) { self.tokenStore = tokenStore + self.cacheEncryptionKeyStore = cacheEncryptionKeyStore self.defaults = defaults self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:)) @@ -53,7 +59,12 @@ final class SessionStore { if hasToken, let storedServerURL { isSignedIn = true - (apiClient, cachingClient) = Self.makeAPIClient(serverURL: storedServerURL, tokenStore: tokenStore, cache: cacheStore) + (apiClient, cachingClient) = Self.makeAPIClient( + serverURL: storedServerURL, + tokenStore: tokenStore, + cacheEncryptionKeyStore: cacheEncryptionKeyStore, + cache: cacheStore + ) userPreferences = Self.loadCachedPreferences(defaults: defaults) } else { // Keychain and the sandboxed UserDefaults container don't @@ -73,7 +84,12 @@ final class SessionStore { func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) { defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey) - (apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore) + (apiClient, cachingClient) = Self.makeAPIClient( + serverURL: serverURL, + tokenStore: tokenStore, + cacheEncryptionKeyStore: cacheEncryptionKeyStore, + cache: cacheStore + ) apply(user: user, team: team, serverURL: serverURL) isSignedIn = true } @@ -99,6 +115,7 @@ final class SessionStore { private static func makeAPIClient( serverURL: URL, tokenStore: TokenStoring, + cacheEncryptionKeyStore: CacheEncryptionKeyStoring, cache: OfflineCacheStore? ) -> (OutlineAPIClient, CachingOutlineAPIClient?) { let live = LiveOutlineAPIClient( @@ -106,12 +123,22 @@ final class SessionStore { tokenStore: tokenStore ) guard let cache else { return (live, nil) } - let caching = CachingOutlineAPIClient(live: live, cache: cache) + let caching = CachingOutlineAPIClient(live: live, cache: cache, encryptionKeyStore: cacheEncryptionKeyStore) return (caching, caching) } - func signOut() { + /// Clears everything scoped to this sign-in: the API token, the offline + /// cache's encryption key, and the cache/pending-write storage itself + /// (in that order — wiping storage before the key would leave it + /// readable a moment longer than necessary, and wiping the key without + /// the storage would leave permanently-undecryptable rows sitting + /// around instead of actually freeing anything). Whoever signs in next + /// on this machine gets a clean slate, not a previous account's + /// leftover cached content. + func signOut() async { try? tokenStore.clear() + await cachingClient?.clearEverythingForSignOut() + try? cacheEncryptionKeyStore.clear() defaults.removeObject(forKey: Self.serverURLDefaultsKey) isSignedIn = false userId = nil diff --git a/Outpost/Support/View+LogoutConfirmation.swift b/Outpost/Support/View+LogoutConfirmation.swift index 6675154..8fe3b61 100644 --- a/Outpost/Support/View+LogoutConfirmation.swift +++ b/Outpost/Support/View+LogoutConfirmation.swift @@ -8,7 +8,7 @@ extension View { titleVisibility: .visible ) { Button("Log Out", role: .destructive) { - session.signOut() + Task { await session.signOut() } } Button("Cancel", role: .cancel) {} } message: { From 8ca5735019caecfb4b98ae81d7d0b4c5a152ad7b Mon Sep 17 00:00:00 2001 From: psmattas Date: Fri, 21 Aug 2026 01:23:48 +0100 Subject: [PATCH 09/12] feat(app): tip jar (StoreKit consumables) in Settings -> About Four consumable IAP tiers - Small (0.99), Medium (2.99), Large (4.99), Generous (9.99), product ids com.psmattas.OutpostApp.tip.{small, medium,large,generous}. TipJarStore loads them via Product.products(for:), purchases via product.purchase(), finishes the transaction immediately on success - consumables have no entitlement to persist or restore (a tip doesn't unlock anything), so there's none of the Transaction.currentEntitlements restore-on-launch logic a real purchase would need. TipJarView shows one button per tier (price + name, StoreKit's own localized display strings) with a "Thank you!" after success or a plain message on failure - no manual retry button, tapping a tier again just re-attempts. Wired into AboutInfoView between the source link and the copyright line. Added Configuration.storekit (4 products matching the IDs above) for local testing in Xcode without needing real App Store Connect products yet - enable it via Edit Scheme -> Run/Preview -> Options -> StoreKit Configuration. Before actually shipping, the same 4 product IDs need to exist for real in App Store Connect (Consumable type, matching reference names) - the local file doesn't create anything there. Not compiler-verified - Outpost app target has no CLI build path. --- Outpost/Configuration.storekit | 74 ++++++++++++++++++++++++ Outpost/Features/About/AboutView.swift | 5 ++ Outpost/Features/About/TipJarView.swift | 75 +++++++++++++++++++++++++ Outpost/Support/TipJarStore.swift | 71 +++++++++++++++++++++++ 4 files changed, 225 insertions(+) create mode 100644 Outpost/Configuration.storekit create mode 100644 Outpost/Features/About/TipJarView.swift create mode 100644 Outpost/Support/TipJarStore.swift diff --git a/Outpost/Configuration.storekit b/Outpost/Configuration.storekit new file mode 100644 index 0000000..9ce6e12 --- /dev/null +++ b/Outpost/Configuration.storekit @@ -0,0 +1,74 @@ +{ + "identifier" : "12E4A6D1-6B8A-4C2E-9F3A-0D1B2C3E4F5A", + "nonRenewingSubscriptions" : [], + "products" : [ + { + "displayPrice" : "0.99", + "familyShareable" : false, + "internalID" : "992ABE97-F5C4-4F80-8FB0-382BDF47DAB6", + "localizations" : [ + { + "description" : "A small tip to support Outpost's development.", + "displayName" : "Small Tip", + "locale" : "en_US" + } + ], + "productID" : "com.psmattas.OutpostApp.tip.small", + "referenceName" : "Small Tip", + "type" : "Consumable" + }, + { + "displayPrice" : "2.99", + "familyShareable" : false, + "internalID" : "316DEB73-6BA3-4F6B-BD54-D17A1CE61938", + "localizations" : [ + { + "description" : "A medium tip to support Outpost's development.", + "displayName" : "Medium Tip", + "locale" : "en_US" + } + ], + "productID" : "com.psmattas.OutpostApp.tip.medium", + "referenceName" : "Medium Tip", + "type" : "Consumable" + }, + { + "displayPrice" : "4.99", + "familyShareable" : false, + "internalID" : "37936F94-AC21-4A39-BC85-CAE6609E170D", + "localizations" : [ + { + "description" : "A large tip to support Outpost's development.", + "displayName" : "Large Tip", + "locale" : "en_US" + } + ], + "productID" : "com.psmattas.OutpostApp.tip.large", + "referenceName" : "Large Tip", + "type" : "Consumable" + }, + { + "displayPrice" : "9.99", + "familyShareable" : false, + "internalID" : "E072C1AC-2719-483E-8A54-F4924808B724", + "localizations" : [ + { + "description" : "A generous tip to support Outpost's development.", + "displayName" : "Generous Tip", + "locale" : "en_US" + } + ], + "productID" : "com.psmattas.OutpostApp.tip.generous", + "referenceName" : "Generous Tip", + "type" : "Consumable" + } + ], + "settings" : { + "_askToBuyEnabled" : false + }, + "subscriptionGroups" : [], + "version" : { + "major" : 3, + "minor" : 0 + } +} diff --git a/Outpost/Features/About/AboutView.swift b/Outpost/Features/About/AboutView.swift index 9137273..4246a88 100644 --- a/Outpost/Features/About/AboutView.swift +++ b/Outpost/Features/About/AboutView.swift @@ -48,6 +48,11 @@ struct AboutInfoView: View { } .font(.callout) + Divider() + .frame(maxWidth: 240) + + TipJarView() + Text("© \(copyrightYear) Puranjay Savar Mattas") .font(.caption2) .foregroundStyle(.tertiary) diff --git a/Outpost/Features/About/TipJarView.swift b/Outpost/Features/About/TipJarView.swift new file mode 100644 index 0000000..ee73611 --- /dev/null +++ b/Outpost/Features/About/TipJarView.swift @@ -0,0 +1,75 @@ +#if os(macOS) +import SwiftUI +import StoreKit + +/// One button per consumable tip tier — no "restore purchases" (nothing to +/// restore, consumables aren't entitlements) and no manual retry: a failed +/// load just shows a message, tapping a tier again re-attempts naturally. +struct TipJarView: View { + @State private var store = TipJarStore() + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Support Outpost") + .font(.callout.weight(.semibold)) + + if store.isLoading && store.products.isEmpty { + ProgressView() + .controlSize(.small) + } else if !store.products.isEmpty { + HStack(spacing: 8) { + ForEach(store.products) { product in + tipButton(for: product) + } + } + } + + switch store.purchaseState { + case .thankYou: + Label("Thank you!", systemImage: "heart.fill") + .font(.caption) + .foregroundStyle(.pink) + case .failed(let message): + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + case .idle, .purchasing: + EmptyView() + } + } + .task { await store.loadProductsIfNeeded() } + } + + private func tipButton(for product: Product) -> some View { + Button { + Task { await store.purchase(product) } + } label: { + VStack(spacing: 2) { + if isPurchasing(product) { + ProgressView() + .controlSize(.small) + } else { + Text(product.displayPrice) + .font(.callout.weight(.semibold)) + } + Text(product.displayName) + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(minWidth: 64) + .padding(.vertical, 6) + } + .buttonStyle(.bordered) + .disabled(isAnyPurchaseInFlight) + } + + private func isPurchasing(_ product: Product) -> Bool { + store.purchaseState == .purchasing(product.id) + } + + private var isAnyPurchaseInFlight: Bool { + if case .purchasing = store.purchaseState { return true } + return false + } +} +#endif diff --git a/Outpost/Support/TipJarStore.swift b/Outpost/Support/TipJarStore.swift new file mode 100644 index 0000000..1374e2c --- /dev/null +++ b/Outpost/Support/TipJarStore.swift @@ -0,0 +1,71 @@ +import StoreKit +import Observation + +/// Backs the tip jar in Settings → About. Consumables only — a tip doesn't +/// unlock anything, so there's no entitlement to persist or restore, and +/// finishing the transaction immediately (rather than checking +/// `Transaction.currentEntitlements` on launch, the way a real purchase +/// would need to) is correct here. +@MainActor +@Observable +final class TipJarStore { + enum PurchaseState: Equatable { + case idle + case purchasing(String) + case thankYou(String) + case failed(String) + } + + /// Must match the consumable In-App Purchase products created in App + /// Store Connect for this app exactly, including the bundle id prefix. + static let productIDs = [ + "com.psmattas.OutpostApp.tip.small", + "com.psmattas.OutpostApp.tip.medium", + "com.psmattas.OutpostApp.tip.large", + "com.psmattas.OutpostApp.tip.generous" + ] + + private(set) var products: [Product] = [] + private(set) var isLoading = false + var purchaseState: PurchaseState = .idle + + /// Tip options don't change during a session — no reason to refetch + /// every time the About screen appears. + func loadProductsIfNeeded() async { + guard products.isEmpty, !isLoading else { return } + isLoading = true + defer { isLoading = false } + do { + let fetched = try await Product.products(for: Self.productIDs) + // Keep the order defined above (small -> generous), not + // whatever order the App Store happens to return them in. + products = Self.productIDs.compactMap { id in fetched.first { $0.id == id } } + if products.isEmpty { + purchaseState = .failed("Tip options aren't available right now.") + } + } catch { + purchaseState = .failed("Couldn't load tip options. Check your connection and try again.") + } + } + + func purchase(_ product: Product) async { + purchaseState = .purchasing(product.id) + do { + switch try await product.purchase() { + case .success(let verification): + guard case .verified(let transaction) = verification else { + purchaseState = .failed("Couldn't verify this purchase.") + return + } + await transaction.finish() + purchaseState = .thankYou(product.id) + case .userCancelled, .pending: + purchaseState = .idle + @unknown default: + purchaseState = .idle + } + } catch { + purchaseState = .failed("Something went wrong completing the purchase.") + } + } +} From 5de445daa53c13aa6ba993dd3f6eaa0f9879a66c Mon Sep 17 00:00:00 2001 From: psmattas Date: Fri, 21 Aug 2026 01:38:11 +0100 Subject: [PATCH 10/12] feat(app): rewire account footer menu - App Store feedback, support link Center-aligned TipJarView's "Support Outpost" heading and thank-you/ error text to match the rest of AboutInfoView (was VStack(alignment: .leading), out of place among everything else there being centered). AccountFooter's Documentation/API Documentation/Changelog links pointed at Outpost's own repo or the signed-in Outline server's own /developers page - not actually useful here, removed along with the now-unused repositoryURL/issuesURL/apiDocumentationURL. Send Us Feedback and Report a Bug (previously both just opening the Gitea issues page) collapsed into one "Leave Us Feedback" wired to Apple's native requestReview() prompt - there's no separate Apple-native channel for "bug" vs "feedback", so one button covers both. Added "Support Outpost" near the bottom of the same menu, alongside Profile/Settings (same visual weight, not pinned to the top) - opens Settings -> About, same navigation the app-menu's "About Outpost" command already uses. Also checking in the shared Xcode scheme (previously untracked/ nonexistent) now that it references Configuration.storekit, so the StoreKit testing setup travels with the repo instead of being machine-local. Not compiler-verified - Outpost app target has no CLI build path. --- .../xcshareddata/xcschemes/Outpost.xcscheme | 101 ++++++++++++++++++ Outpost/Features/About/TipJarView.swift | 2 +- Outpost/Features/Account/AccountFooter.swift | 32 +++--- 3 files changed, 116 insertions(+), 19 deletions(-) create mode 100644 Outpost.xcodeproj/xcshareddata/xcschemes/Outpost.xcscheme diff --git a/Outpost.xcodeproj/xcshareddata/xcschemes/Outpost.xcscheme b/Outpost.xcodeproj/xcshareddata/xcschemes/Outpost.xcscheme new file mode 100644 index 0000000..cf7bbda --- /dev/null +++ b/Outpost.xcodeproj/xcshareddata/xcschemes/Outpost.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Outpost/Features/About/TipJarView.swift b/Outpost/Features/About/TipJarView.swift index ee73611..2bf7902 100644 --- a/Outpost/Features/About/TipJarView.swift +++ b/Outpost/Features/About/TipJarView.swift @@ -9,7 +9,7 @@ struct TipJarView: View { @State private var store = TipJarStore() var body: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(spacing: 8) { Text("Support Outpost") .font(.callout.weight(.semibold)) diff --git a/Outpost/Features/Account/AccountFooter.swift b/Outpost/Features/Account/AccountFooter.swift index e4c2033..885a615 100644 --- a/Outpost/Features/Account/AccountFooter.swift +++ b/Outpost/Features/Account/AccountFooter.swift @@ -1,5 +1,6 @@ #if os(macOS) import SwiftUI +import StoreKit import OutlineKit /// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label @@ -9,20 +10,13 @@ import OutlineKit struct AccountFooter: View { @Environment(SessionStore.self) private var session @Environment(AppNavigation.self) private var navigation - @Environment(\.openURL) private var openURL + @Environment(\.requestReview) private var requestReview @AppStorage("outpost.appearance") private var appearance: AppAppearance = .system @AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false @State private var isMenuPresented = false @State private var isShowingLogoutConfirmation = false @State private var isShowingProfile = false - private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")! - private let issuesURL = URL(string: "https://git.psmattas.com/psmattas/Outpost/issues")! - - private var apiDocumentationURL: URL? { - session.serverURL?.appendingPathComponent("developers") - } - var body: some View { Button { isMenuPresented = true @@ -62,16 +56,10 @@ struct AccountFooter: View { private var menuContent: some View { VStack(alignment: .leading, spacing: 2) { - menuItem("Documentation") { openURL(repositoryURL) } - if let apiDocumentationURL { - menuItem("API Documentation") { openURL(apiDocumentationURL) } - } - menuItem("Changelog") { openURL(repositoryURL) } - - Divider() - - menuItem("Send Us Feedback") { openURL(issuesURL) } - menuItem("Report a Bug") { openURL(issuesURL) } + // Apple's own review/feedback prompt — there's no separate + // native channel for "bug" vs. "feedback", so one button covers + // both. + menuItem("Leave Us Feedback") { requestReview() } Divider() @@ -101,6 +89,14 @@ struct AccountFooter: View { // as "Invalid attempt to open a new transaction during CA // commit") — letting the popover's dismissal finish first avoids it. menuItem("Settings…") { Task { @MainActor in navigation.isShowingSettings = true } } + // Same deferred-Task reasoning as Settings… above — this also + // sets isShowingSettings synchronously. + menuItem("Support Outpost") { + Task { @MainActor in + navigation.selectedSettingsSection = .about + navigation.isShowingSettings = true + } + } Divider() From b8ce517b6065eff0895ddb026e90e1b1d04f5296 Mon Sep 17 00:00:00 2001 From: psmattas Date: Fri, 21 Aug 2026 01:48:20 +0100 Subject: [PATCH 11/12] perf: stop recomputing derived state on every SwiftUI render An audit for v0.1.0 turned up the same pattern in four places: a computed property doing real work (filtering/sorting/scoring a collection), read multiple times per render including from unrelated state changes (selection, hover, scroll), so the work reran far more often than the underlying data actually changed. Converted each to a @State cache recomputed only via onChange of its real inputs: - DocumentSearchSheet: matchingLineIndices re-scanned the whole document per access, read once per visible row plus twice more in the header/step logic - O(n^2) case-insensitive scan per frame on a large document. Also split into an ordered array (for currentMatchIndex/stepping) plus a parallel Set for the per-row highlight check, which was an O(k) linear .contains before. - CollectionDocumentsOutline: tree rebuilt the whole dictionary- grouped, recursively-sorted document tree on every body evaluation, not just when documents/sortOption actually changed. - CommandPaletteView: results re-scored and re-sorted the entire index (up to the whole local workspace cache in Full Workspace mode) on every render, including ones from selectedIndex moving as arrow keys are pressed. - CollectionOverviewView: sortedDocuments re-sorted on every render; same pattern, smaller blast radius (capped at 100 docs). Also: - HomeViewModel.fetchPinnedThrowing fetched each pinned document serially in a for loop (one round trip at a time) - switched to a TaskGroup so latency doesn't scale with pin count, results reordered back to pins.list's own order since task completion order isn't submission order. - AvatarCropperView.renderFinalImage ran ImageRenderer + JPEG compression synchronously on the main actor from the "Use Photo" button tap. ImageRenderer itself has to stay on the main actor (it captures live SwiftUI state), but JPEG compression on the already- rendered bitmap has no SwiftUI dependency left - hopped that part to a detached Task via tiffRepresentation (plain Data, unlike NSImage itself isn't Sendable) so it doesn't hitch the UI. No crash risks or retain cycles found in the same audit (no try!/ as!, force-unwraps essentially absent outside a hardcoded URL literal, weak self already used where it matters) - this is purely the perf half of the findings. Not compiler-verified - Outpost app target has no CLI build path. --- .../Features/Account/AvatarCropperView.swift | 23 +++++++++++--- .../CollectionDocumentsOutline.swift | 14 +++++++-- .../Collections/CollectionOverviewView.swift | 13 ++++++-- .../Collections/CommandPaletteView.swift | 22 ++++++++++--- .../Collections/DocumentSearchSheet.swift | 31 ++++++++++++++++--- Outpost/Features/Home/HomeViewModel.swift | 21 +++++++++---- 6 files changed, 100 insertions(+), 24 deletions(-) diff --git a/Outpost/Features/Account/AvatarCropperView.swift b/Outpost/Features/Account/AvatarCropperView.swift index a66e20f..3ba4c37 100644 --- a/Outpost/Features/Account/AvatarCropperView.swift +++ b/Outpost/Features/Account/AvatarCropperView.swift @@ -74,8 +74,10 @@ struct AvatarCropperView: View { Button("Cancel", role: .cancel, action: onCancel) Spacer() Button("Use Photo") { - if let data = renderFinalImage() { - onConfirm(data) + Task { + if let data = await renderFinalImage() { + onConfirm(data) + } } } .buttonStyle(.borderedProminent) @@ -100,15 +102,26 @@ struct AvatarCropperView: View { .clipped() } + /// `ImageRenderer` itself has to run on the main actor (it captures live + /// SwiftUI view state), but JPEG compression on the bitmap it produces + /// is pure CPU work with no SwiftUI dependency left — hopping off for + /// just that part avoids a visible hitch on tapping "Use Photo". + /// `tiffRepresentation` (plain `Data`, unlike `NSImage` itself) is what + /// actually crosses the actor boundary; mirrors `NSImage.jpegData( + /// compressionQuality:)`'s own logic rather than calling it directly, so + /// crossing doesn't require handing a non-Sendable `NSImage` to a + /// detached task. @MainActor - private func renderFinalImage() -> Data? { + private func renderFinalImage() async -> Data? { let content = avatarContent .clipShape(Circle()) .frame(width: diameter, height: diameter) let renderer = ImageRenderer(content: content) renderer.scale = 2 // @2x so it isn't a blurry 320px avatar on Retina displays - guard let nsImage = renderer.nsImage else { return nil } - return nsImage.jpegData(compressionQuality: 0.9) + guard let tiffData = renderer.nsImage?.tiffRepresentation else { return nil } + return await Task.detached(priority: .userInitiated) { + NSBitmapImageRep(data: tiffData)?.representation(using: .jpeg, properties: [.compressionFactor: 0.9]) + }.value } } #endif diff --git a/Outpost/Features/Collections/CollectionDocumentsOutline.swift b/Outpost/Features/Collections/CollectionDocumentsOutline.swift index f05baf5..259bdb4 100644 --- a/Outpost/Features/Collections/CollectionDocumentsOutline.swift +++ b/Outpost/Features/Collections/CollectionDocumentsOutline.swift @@ -30,8 +30,15 @@ struct CollectionDocumentsOutline: View { /// every document in the tree. @State private var pinsByDocumentID: [String: OutlinePin] = [:] - private var tree: [DocumentNode] { - buildDocumentTree(from: viewModel.documents, sortedBy: sortOption) + /// Recomputed only when `viewModel.documents`/`sortOption` actually change + /// (below) instead of being a computed property — this rebuilt the whole + /// dictionary-grouped, recursively-sorted tree on every `body` evaluation, + /// including renders triggered by unrelated state (selection, hover, + /// pins) that don't change the tree's shape at all. + @State private var tree: [DocumentNode] = [] + + private func rebuildTree() { + tree = buildDocumentTree(from: viewModel.documents, sortedBy: sortOption) } init( @@ -86,7 +93,10 @@ struct CollectionDocumentsOutline: View { .task(id: "\(refreshToken)-\(externalRefreshToken)") { await viewModel.load() await loadPins() + rebuildTree() } + .onChange(of: viewModel.documents) { rebuildTree() } + .onChange(of: sortOption) { rebuildTree() } } private func loadPins() async { diff --git a/Outpost/Features/Collections/CollectionOverviewView.swift b/Outpost/Features/Collections/CollectionOverviewView.swift index d37a8ce..9c1c013 100644 --- a/Outpost/Features/Collections/CollectionOverviewView.swift +++ b/Outpost/Features/Collections/CollectionOverviewView.swift @@ -34,8 +34,15 @@ struct CollectionOverviewView: View { self.onOpenDocument = onOpenDocument } - private var sortedDocuments: [OutlineDocument] { - selectedTab.sorted(viewModel.documents) + /// Recomputed only when `viewModel.documents`/`selectedTab` actually + /// change (below) instead of being a computed property re-sorted on + /// every render — capped at 100 documents per collection page, so lower + /// blast radius than the sidebar/command-palette versions of this same + /// pattern, but the same fix. + @State private var sortedDocuments: [OutlineDocument] = [] + + private func resortDocuments() { + sortedDocuments = selectedTab.sorted(viewModel.documents) } var body: some View { @@ -94,6 +101,8 @@ struct CollectionOverviewView: View { await viewModel.checkForRemoteChanges() } } + .onChange(of: viewModel.documents) { resortDocuments() } + .onChange(of: selectedTab) { resortDocuments() } } // Spans the full window width, centered, directly under the toolbar — diff --git a/Outpost/Features/Collections/CommandPaletteView.swift b/Outpost/Features/Collections/CommandPaletteView.swift index fc7d5e7..be17186 100644 --- a/Outpost/Features/Collections/CommandPaletteView.swift +++ b/Outpost/Features/Collections/CommandPaletteView.swift @@ -41,22 +41,31 @@ struct CommandPaletteView: View { } } - private var results: [Result] { + /// Recomputed only when `query`/`collections`/`documents` actually change + /// (below) instead of being a computed property — Full Workspace mode's + /// index can be large (every document in the local cache, sub-documents + /// included), and this was re-scanning + re-sorting the entire thing on + /// every render, including ones triggered by unrelated state like + /// `selectedIndex` changing as arrow keys move the selection. + @State private var results: [Result] = [] + + private func recomputeResults() { let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { // No query yet: surface collections first, then the most // recent/full-workspace documents as-is, capped so the panel // doesn't dump the entire workspace with nothing typed. - return (collections.map(Result.collection) + documents.map(Result.document)) + results = (collections.map(Result.collection) + documents.map(Result.document)) .prefix(20) .map { $0 } + return } let scored: [(Result, Int)] = collections.compactMap { collection in matchScore(collection.name, query: trimmed).map { (Result.collection(collection), $0) } } + documents.compactMap { document in matchScore(document.title, query: trimmed).map { (Result.document(document), $0) } } - return scored.sorted { $0.1 < $1.1 }.prefix(30).map(\.0) + results = scored.sorted { $0.1 < $1.1 }.prefix(30).map(\.0) } /// Lower is better — exact match, then prefix match, then earliest @@ -100,7 +109,10 @@ struct CommandPaletteView: View { .textFieldStyle(.plain) .font(.title3) .focused($isSearchFieldFocused) - .onChange(of: query) { selectedIndex = 0 } + .onChange(of: query) { + selectedIndex = 0 + recomputeResults() + } .onSubmit { selectCurrent() } // Attached directly on the field itself, not an // ancestor — confirmed live that .onKeyPress on the @@ -169,6 +181,8 @@ struct CommandPaletteView: View { isSearchFieldFocused = true await loadResults() } + .onChange(of: collections) { recomputeResults() } + .onChange(of: documents) { recomputeResults() } } private func resultRow(_ result: Result, isSelected: Bool) -> some View { diff --git a/Outpost/Features/Collections/DocumentSearchSheet.swift b/Outpost/Features/Collections/DocumentSearchSheet.swift index 849e8cc..1ec14dd 100644 --- a/Outpost/Features/Collections/DocumentSearchSheet.swift +++ b/Outpost/Features/Collections/DocumentSearchSheet.swift @@ -22,9 +22,26 @@ struct DocumentSearchSheet: View { @State private var currentMatchIndex = 0 @FocusState private var isSearchFieldFocused: Bool - private var matchingLineIndices: [Int] { - guard !query.isEmpty else { return [] } - return lines.indices.filter { lines[$0].localizedCaseInsensitiveContains(query) } + /// Recomputed only when `query`/`lines` actually change (`recomputeMatches()`) + /// instead of being a computed property — this used to re-scan the whole + /// document on every access, and it's read multiple times per row + /// (`isCurrentMatch`, the highlight check) on every SwiftUI re-render, so a + /// large document turned into an O(n²) case-insensitive scan per frame. + @State private var matchingLineIndices: [Int] = [] + /// O(1) membership for the per-row highlight check below — `matchingLineIndices` + /// stays an ordered array (needed for `currentMatchIndex`/stepping), this is + /// just a parallel lookup so a common search term with many matches doesn't + /// make every row's highlight check an O(k) linear scan. + @State private var matchingLineIndexSet: Set = [] + + private func recomputeMatches() { + guard !query.isEmpty else { + matchingLineIndices = [] + matchingLineIndexSet = [] + return + } + matchingLineIndices = lines.indices.filter { lines[$0].localizedCaseInsensitiveContains(query) } + matchingLineIndexSet = Set(matchingLineIndices) } var body: some View { @@ -35,7 +52,10 @@ struct DocumentSearchSheet: View { TextField("Search in \"\(document.title.isEmpty ? "Untitled" : document.title)\"", text: $query) .textFieldStyle(.plain) .focused($isSearchFieldFocused) - .onChange(of: query) { currentMatchIndex = 0 } + .onChange(of: query) { + currentMatchIndex = 0 + recomputeMatches() + } if !matchingLineIndices.isEmpty { Text("\(currentMatchIndex + 1) of \(matchingLineIndices.count)") @@ -83,7 +103,7 @@ struct DocumentSearchSheet: View { .padding(.horizontal, 4) .background( isCurrentMatch(index) ? Color.yellow.opacity(0.4) - : matchingLineIndices.contains(index) ? Color.yellow.opacity(0.15) + : matchingLineIndexSet.contains(index) ? Color.yellow.opacity(0.15) : Color.clear ) .id(index) @@ -131,6 +151,7 @@ struct DocumentSearchSheet: View { do { let text = try await apiClient.documentInfo(id: document.id).text lines = text.components(separatedBy: "\n") + recomputeMatches() } catch { errorMessage = outlineErrorMessage(error, fallback: "Couldn't load this document.") } diff --git a/Outpost/Features/Home/HomeViewModel.swift b/Outpost/Features/Home/HomeViewModel.swift index 01f6ae8..825526b 100644 --- a/Outpost/Features/Home/HomeViewModel.swift +++ b/Outpost/Features/Home/HomeViewModel.swift @@ -83,16 +83,25 @@ final class HomeViewModel { /// `pins.list` only returns pin records, not the documents themselves — /// fetches each pinned document individually. Pins are a small curated /// set (unlike a full collection tree), so the N+1 here is acceptable - /// where it wouldn't be in the sidebar. + /// where it wouldn't be in the sidebar — but they're fetched concurrently + /// (a `TaskGroup`, not a serial loop) so latency doesn't scale with pin + /// count; `documentInfo` already goes through `CachingOutlineAPIClient`'s + /// own cached-read/retry path either way. private func fetchPinnedThrowing() async throws -> [OutlineDocument] { 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) { - documents.append(document) + let client = apiClient + let documentsByID: [String: OutlineDocument] = await withTaskGroup(of: (String, OutlineDocument?).self) { group in + for pin in pins { + group.addTask { (pin.documentId, try? await client.documentInfo(id: pin.documentId)) } } + var result: [String: OutlineDocument] = [:] + for await (id, document) in group { + if let document { result[id] = document } + } + return result } - return documents + // Preserve pins.list's own order rather than task-completion order. + return pins.compactMap { documentsByID[$0.documentId] } } private func fetch(tab: HomeTab) async throws -> [OutlineDocument] { From 8db8e985a7b314013db185cfafcb179257f9cce2 Mon Sep 17 00:00:00 2001 From: psmattas Date: Fri, 21 Aug 2026 01:58:39 +0100 Subject: [PATCH 12/12] release: drop alpha framing, TestFlight badge -> Mac App Store, bump to 0.1.0 App Store link: https://apps.apple.com/us/app/outpost-for-outline/id6802736230 README's TestFlight badge replaced with Apple's official "Download on the Mac App Store" badge (docs/assets/mac-app-store-badge.svg, black lockup, from Apple's official marketing badge kit), linked to the real App Store listing. "Early alpha" language dropped from README, CONTRIBUTING.md, SECURITY.md, and both Gitea issue templates - these are now "0.1.x"/"early" rather than "0.0.x"/"alpha", matching the actual release. OutpostVersion.releaseStage is now "" instead of "ALPHA" - About page and the Settings sidebar footer both read through this single source of truth, so this alone drops the "-ALPHA" suffix everywhere it was shown without touching either call site. MARKETING_VERSION bumped 0.0.4 -> 0.1.0 for the Outpost target (Debug + Release) - left OutpostTests/OutpostUITests' MARKETING_VERSION alone, that's just Xcode's unrelated template default for test bundles, never shown to a user. Not compiler-verified - Outpost app target has no CLI build path. --- .gitea/issue_template/bug_report.yaml | 2 +- .gitea/issue_template/feature_request.yaml | 2 +- CONTRIBUTING.md | 2 +- Outpost.xcodeproj/project.pbxproj | 4 +- Outpost/Support/OutpostVersion.swift | 12 ++--- README.md | 8 ++-- SECURITY.md | 6 +-- docs/assets/mac-app-store-badge.svg | 51 ++++++++++++++++++++++ 8 files changed, 70 insertions(+), 17 deletions(-) create mode 100644 docs/assets/mac-app-store-badge.svg diff --git a/.gitea/issue_template/bug_report.yaml b/.gitea/issue_template/bug_report.yaml index 69f47c1..7b4f53e 100644 --- a/.gitea/issue_template/bug_report.yaml +++ b/.gitea/issue_template/bug_report.yaml @@ -6,7 +6,7 @@ body: - type: markdown attributes: value: | - Outpost is early alpha — please check the version in About (or your build's commit) is current before filing, and mention which platform (macOS only, for now) and OS version you're on. + Outpost is early — please check the version in About (or your build's commit) is current before filing, and mention which platform (macOS only, for now) and OS version you're on. - type: input id: summary attributes: diff --git a/.gitea/issue_template/feature_request.yaml b/.gitea/issue_template/feature_request.yaml index 3c89634..6a40019 100644 --- a/.gitea/issue_template/feature_request.yaml +++ b/.gitea/issue_template/feature_request.yaml @@ -6,7 +6,7 @@ body: - type: markdown attributes: value: | - Outpost is early alpha and tracking Outline's own web app for parity (see [`CLAUDE.md`](../../CLAUDE.md) for the phased build order) — a request that's "just do what web Outline does" is easier to act on than a net-new idea. + Outpost is early and tracking Outline's own web app for parity (see [`CLAUDE.md`](../../CLAUDE.md) for the phased build order) — a request that's "just do what web Outline does" is easier to act on than a net-new idea. - type: input id: summary attributes: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1c2654..3c13016 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ Thank you for contributing. Please read this guide before opening issues or PRs. -Outpost is early alpha (`0.0.x`) — expect the codebase and conventions here to shift as Phase 1 (see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)) settles. If something in this guide is stale, flag it. +Outpost is early (`0.1.x`) — expect the codebase and conventions here to keep evolving as later phases (see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md)) land. If something in this guide is stale, flag it. --- diff --git a/Outpost.xcodeproj/project.pbxproj b/Outpost.xcodeproj/project.pbxproj index 32e47d7..99d4028 100644 --- a/Outpost.xcodeproj/project.pbxproj +++ b/Outpost.xcodeproj/project.pbxproj @@ -425,7 +425,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 27.0; - MARKETING_VERSION = 0.0.4; + MARKETING_VERSION = 0.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -477,7 +477,7 @@ LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 27.0; - MARKETING_VERSION = 0.0.4; + MARKETING_VERSION = 0.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.OutpostApp; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/Outpost/Support/OutpostVersion.swift b/Outpost/Support/OutpostVersion.swift index 85aee99..8245ce2 100644 --- a/Outpost/Support/OutpostVersion.swift +++ b/Outpost/Support/OutpostVersion.swift @@ -7,24 +7,26 @@ import Foundation enum OutpostVersion { /// Bumped alongside `MARKETING_VERSION` in the Xcode project — kept out /// of the bundle version itself since `CFBundleShortVersionString` is - /// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`. - static let releaseStage = "ALPHA" + /// expected to stay a plain dotted-numeric string, not `0.1.0-ALPHA`. + /// Empty since the App Store release (no more alpha/beta suffix) — + /// `displayString`/`fullVersionString` just show the plain version now. + static let releaseStage = "" static var shortVersion: String { - Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1" + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.1.0" } static var buildNumber: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1" } - /// e.g. `"0.0.3-ALPHA"` — for compact display (sidebar footer). + /// e.g. `"0.1.0"` — for compact display (sidebar footer). static var displayString: String { let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)" return "\(shortVersion)\(stageSuffix)" } - /// e.g. `"Version 0.0.3-ALPHA (1)"` — for the About page. + /// e.g. `"Version 0.1.0 (1)"` — for the About page. static var fullVersionString: String { "Version \(displayString) (\(buildNumber))" } diff --git a/README.md b/README.md index a795d6c..bca7389 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,14 @@

Outpost

- - Download on TestFlight + + Download on the Mac App Store

A native Apple ecosystem client for [Outline](https://github.com/outline/outline) — built for iOS, iPadOS, and macOS from a single SwiftUI codebase, aiming for full editing parity with Outline's web app, including realtime collaborative editing. -> **Early alpha — macOS only for now.** Expect missing features and rough edges. iOS/iPadOS support is planned but not in the current build. See the [releases page](https://git.psmattas.com/psmattas/Outpost/releases) for changelogs, and [open an issue](https://git.psmattas.com/psmattas/Outpost/issues) if you hit anything. +> **macOS only for now.** Expect missing features and rough edges. iOS/iPadOS support is planned but not in the current build. See the [releases page](https://git.psmattas.com/psmattas/Outpost/releases) for changelogs, and [open an issue](https://git.psmattas.com/psmattas/Outpost/issues) if you hit anything. ## Why @@ -21,7 +21,7 @@ Outline's web app is great, but there's no native Apple client with full editing ## Requirements - Xcode 27+ (currently developed against an Xcode 27 beta — this is a hard minimum, not a suggestion) -- macOS 27+. iOS/iPadOS support is planned but not in the current build (see the alpha note above) — same 27+ minimum will apply once it lands +- macOS 27+. iOS/iPadOS support is planned but not in the current build (see the note above) — same 27+ minimum will apply once it lands - A self-hosted (or hosted) Outline instance with API access ## Setup diff --git a/SECURITY.md b/SECURITY.md index 2af656c..a9e15b3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -18,9 +18,9 @@ within 14 days depending on severity. ## Supported Versions -Outpost is in early alpha (`0.0.x`) — there's no stable release line -yet. Only the most recent tagged release receives fixes; please make -sure you're on the latest alpha before reporting. +Outpost is early (`0.1.x`) — there's no stable release line yet. Only +the most recent tagged release receives fixes; please make sure +you're on the latest release before reporting. | Version | Supported | | :--- | :---: | diff --git a/docs/assets/mac-app-store-badge.svg b/docs/assets/mac-app-store-badge.svg new file mode 100644 index 0000000..c36a76a --- /dev/null +++ b/docs/assets/mac-app-store-badge.svg @@ -0,0 +1,51 @@ + + Download_on_the_Mac_App_Store_Badge_US-UK_RGB_blk_092917 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +