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/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 3c9bf20..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,11 +37,33 @@ 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? - public init(live: OutlineAPIClient, cache: OfflineCacheStore, defaults: UserDefaults = .standard) { + /// 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, + encryptionKeyStore: CacheEncryptionKeyStoring = KeychainCacheEncryptionKeyStore() + ) { self.live = live self.cache = cache self.defaults = defaults + self.encryptionKeyStore = encryptionKeyStore let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 @@ -62,7 +85,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 +95,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 +106,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 +144,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 +159,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 +174,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 +188,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 +204,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 +218,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 +234,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 +248,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 +278,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 } @@ -445,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 { @@ -553,43 +625,87 @@ 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 - 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 // 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)) } do { - let result = try await fetch() - if let data = try? encoder.encode(result) { + let result = try await RetryPolicy.withRetry { try await fetch() } + recordSuccess(category: category) + if let data = encryptedEncode(result) { await cache.save(data, forKey: key) } return result } catch { - if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) { + // 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 = 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 @@ -598,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) } @@ -649,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) @@ -678,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, @@ -700,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( @@ -750,44 +866,84 @@ 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) } } + /// 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/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/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..53fc090 --- /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..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.. 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) + } + } +} 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.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/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..2bf7902 --- /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(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/Features/Account/AccountFooter.swift b/Outpost/Features/Account/AccountFooter.swift index 79722ee..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,21 +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(\.openWindow) private var openWindow + @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 @@ -63,20 +56,10 @@ 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) } - } - 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() @@ -106,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() 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/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/Features/Account/SettingsSidebarList.swift b/Outpost/Features/Account/SettingsSidebarList.swift index 9f86f5d..291bdd4 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)") @@ -109,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 d5181cb..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.") @@ -1325,9 +1330,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 +1341,10 @@ struct SettingsView: View { } .frame(maxWidth: 480, alignment: .leading) + Divider() + .frame(maxWidth: 480) + */ + Divider() .frame(maxWidth: 480) @@ -1400,6 +1410,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) @@ -1503,7 +1516,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) } @@ -1535,7 +1548,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.") @@ -1552,7 +1565,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..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,11 +93,14 @@ 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 { - 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/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/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/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/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..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 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 pins = try await RetryPolicy.withRetry { try await apiClient.listPins(ListPinsRequest(collectionId: nil)) } + 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] { @@ -134,7 +143,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/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/Root/APIFailureCenter.swift b/Outpost/Root/APIFailureCenter.swift new file mode 100644 index 0000000..76f1afd --- /dev/null +++ b/Outpost/Root/APIFailureCenter.swift @@ -0,0 +1,67 @@ +import Foundation +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..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 @@ -136,7 +163,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/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/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) } }) 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.") + } + } +} 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: { 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 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/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 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +