Compare commits
8
Commits
0393fe267e
...
f624ce6c9f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f624ce6c9f
|
||
|
|
20baab78c0
|
||
|
|
1580510cfb
|
||
|
|
5b876d7085
|
||
|
|
5d5cda9cea
|
||
|
|
7369b46b87
|
||
|
|
512c6d22bf
|
||
|
|
aa02153b5d
|
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
/// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with offline
|
/// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with offline
|
||||||
/// support at the existing protocol boundary, so no view model needs to know
|
/// 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 encoder: JSONEncoder
|
||||||
private let decoder: JSONDecoder
|
private let decoder: JSONDecoder
|
||||||
private let keyEncoder: JSONEncoder
|
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.live = live
|
||||||
self.cache = cache
|
self.cache = cache
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
|
self.encryptionKeyStore = encryptionKeyStore
|
||||||
|
|
||||||
let encoder = JSONEncoder()
|
let encoder = JSONEncoder()
|
||||||
encoder.dateEncodingStrategy = .iso8601
|
encoder.dateEncodingStrategy = .iso8601
|
||||||
@@ -62,7 +85,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
// MARK: - Cached reads
|
// MARK: - Cached reads
|
||||||
|
|
||||||
public func documentInfo(id: String) async throws -> OutlineDocument {
|
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(
|
public func listDocuments(
|
||||||
@@ -72,7 +95,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
limit: Int
|
limit: Int
|
||||||
) async throws -> [OutlineDocument] {
|
) async throws -> [OutlineDocument] {
|
||||||
let key = "documents:\(collectionId ?? "-"):\(parentDocumentId ?? "-"):\(offset):\(limit)"
|
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(
|
try await self.live.listDocuments(
|
||||||
collectionId: collectionId,
|
collectionId: collectionId,
|
||||||
parentDocumentId: parentDocumentId,
|
parentDocumentId: parentDocumentId,
|
||||||
@@ -83,29 +106,29 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] {
|
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] {
|
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)
|
try await self.live.listViewedDocuments(offset: offset, limit: limit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func listDrafts(_ request: ListDraftsRequest) async throws -> [OutlineDocument] {
|
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)
|
try await self.live.listDrafts(request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] {
|
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)
|
try await self.live.listCollections(offset: offset, limit: limit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func collectionInfo(id: String) async throws -> OutlineCollection {
|
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
|
// MARK: - Queueable writes
|
||||||
@@ -121,10 +144,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
|
public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
|
||||||
if !isManualOfflineModeEnabled {
|
if !isManualOfflineModeEnabled {
|
||||||
do {
|
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)
|
await cacheDocument(result)
|
||||||
return result
|
return result
|
||||||
} catch {
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "documents-write", error)
|
||||||
return await queueDocumentCreate(request)
|
return await queueDocumentCreate(request)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,10 +159,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
|
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
|
||||||
if !isManualOfflineModeEnabled {
|
if !isManualOfflineModeEnabled {
|
||||||
do {
|
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)
|
await cacheDocument(result)
|
||||||
return result
|
return result
|
||||||
} catch {
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "documents-write", error)
|
||||||
return try await queueDocumentUpdate(request, dueTo: error)
|
return try await queueDocumentUpdate(request, dueTo: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,10 +174,12 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection {
|
public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection {
|
||||||
if !isManualOfflineModeEnabled {
|
if !isManualOfflineModeEnabled {
|
||||||
do {
|
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)
|
await cacheCollection(result)
|
||||||
return result
|
return result
|
||||||
} catch {
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "collections-write", error)
|
||||||
return try await queueCollectionUpdate(request, dueTo: error)
|
return try await queueCollectionUpdate(request, dueTo: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,7 +188,14 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
|
|
||||||
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
|
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
|
||||||
if !isManualOfflineModeEnabled {
|
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)
|
return await queuePinCreate(request)
|
||||||
}
|
}
|
||||||
@@ -168,9 +204,11 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
if await cancelIfNeverSynced(id: id) { return }
|
if await cancelIfNeverSynced(id: id) { return }
|
||||||
if !isManualOfflineModeEnabled {
|
if !isManualOfflineModeEnabled {
|
||||||
do {
|
do {
|
||||||
try await live.deletePin(id: id)
|
try await RetryPolicy.withRetry { try await self.live.deletePin(id: id) }
|
||||||
|
recordSuccess(category: "pins")
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "pins", error)
|
||||||
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
|
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -180,7 +218,14 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
|
|
||||||
public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription {
|
public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription {
|
||||||
if !isManualOfflineModeEnabled {
|
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)
|
return await queueSubscriptionCreate(request)
|
||||||
}
|
}
|
||||||
@@ -189,9 +234,11 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
if await cancelIfNeverSynced(id: id) { return }
|
if await cancelIfNeverSynced(id: id) { return }
|
||||||
if !isManualOfflineModeEnabled {
|
if !isManualOfflineModeEnabled {
|
||||||
do {
|
do {
|
||||||
try await live.deleteSubscription(id: id)
|
try await RetryPolicy.withRetry { try await self.live.deleteSubscription(id: id) }
|
||||||
|
recordSuccess(category: "subscriptions")
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "subscriptions", error)
|
||||||
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
|
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -201,14 +248,28 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
|
|
||||||
public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar {
|
public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar {
|
||||||
if !isManualOfflineModeEnabled {
|
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)
|
return await queueStarDocumentCreate(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar {
|
public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar {
|
||||||
if !isManualOfflineModeEnabled {
|
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)
|
return await queueStarCollectionCreate(request)
|
||||||
}
|
}
|
||||||
@@ -217,9 +278,11 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
if await cancelIfNeverSynced(id: id) { return }
|
if await cancelIfNeverSynced(id: id) { return }
|
||||||
if !isManualOfflineModeEnabled {
|
if !isManualOfflineModeEnabled {
|
||||||
do {
|
do {
|
||||||
try await live.deleteStar(id: id)
|
try await RetryPolicy.withRetry { try await self.live.deleteStar(id: id) }
|
||||||
|
recordSuccess(category: "stars")
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
|
recordWriteFailureIfStructural(category: "stars", error)
|
||||||
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)")
|
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -445,6 +508,15 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
await cache.clearAll()
|
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
|
/// Replays every queued operation against `live`, in the order they were
|
||||||
/// queued. Each is independent — one failing doesn't block the rest.
|
/// queued. Each is independent — one failing doesn't block the rest.
|
||||||
public func flushPendingOperations() async -> SyncFlushSummary {
|
public func flushPendingOperations() async -> SyncFlushSummary {
|
||||||
@@ -553,43 +625,87 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
/// SwiftData read, no network involved.
|
/// SwiftData read, no network involved.
|
||||||
public func cachedDocumentsIndex() async -> [OutlineDocument] {
|
public func cachedDocumentsIndex() async -> [OutlineDocument] {
|
||||||
let payloads = await cache.loadAll(keyPrefix: "document:")
|
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.
|
/// Every individually cached collection from the last Full Local Sync.
|
||||||
public func cachedCollectionsIndex() async -> [OutlineCollection] {
|
public func cachedCollectionsIndex() async -> [OutlineCollection] {
|
||||||
let payloads = await cache.loadAll(keyPrefix: "collection:")
|
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
|
// MARK: - Helpers
|
||||||
|
|
||||||
private func cachedFetch<T: Codable>(key: String, fetch: () async throws -> T) async throws -> T {
|
private func cachedFetch<T: Codable>(key: String, category: String, fetch: () async throws -> T) async throws -> T {
|
||||||
// Manual offline mode means "skip the network entirely," not just
|
// Manual offline mode means "skip the network entirely," not just
|
||||||
// "prefer it" — without this check, a read would still hit `live`
|
// "prefer it" — without this check, a read would still hit `live`
|
||||||
// (and succeed, showing content beyond whatever's cached) any time
|
// (and succeed, showing content beyond whatever's cached) any time
|
||||||
// the device actually had a connection, defeating the point of
|
// the device actually had a connection, defeating the point of
|
||||||
// deliberately testing/working as if offline.
|
// deliberately testing/working as if offline.
|
||||||
if isManualOfflineModeEnabled {
|
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
|
return cached
|
||||||
}
|
}
|
||||||
throw OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
throw OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
let result = try await fetch()
|
let result = try await RetryPolicy.withRetry { try await fetch() }
|
||||||
if let data = try? encoder.encode(result) {
|
recordSuccess(category: category)
|
||||||
|
if let data = encryptedEncode(result) {
|
||||||
await cache.save(data, forKey: key)
|
await cache.save(data, forKey: key)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
} catch {
|
} 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
|
return cached
|
||||||
}
|
}
|
||||||
throw error
|
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<T: Decodable>(_ 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<T: Decodable>(_ 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 {
|
private func requestKey(_ prefix: String, _ request: some Encodable) -> String {
|
||||||
guard let data = try? keyEncoder.encode(request), let json = String(data: data, encoding: .utf8) else {
|
guard let data = try? keyEncoder.encode(request), let json = String(data: data, encoding: .utf8) else {
|
||||||
return prefix
|
return prefix
|
||||||
@@ -598,19 +714,19 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func cacheDocument(_ document: OutlineDocument) async {
|
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)")
|
await cache.save(data, forKey: "document:\(document.id)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cacheCollection(_ collection: OutlineCollection) async {
|
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)")
|
await cache.save(data, forKey: "collection:\(collection.id)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func enqueue(_ kind: PendingOperationKind, payload: some Encodable, id: String) async {
|
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)
|
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 {
|
private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument {
|
||||||
guard let baseData = await cache.load(forKey: "document:\(request.id)"),
|
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))
|
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
||||||
}
|
}
|
||||||
let mergedText = request.append == true ? base.text + (request.text ?? "") : (request.text ?? base.text)
|
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.
|
// of it and the update would just fail every retry.
|
||||||
if merged.id.hasPrefix("pending-"),
|
if merged.id.hasPrefix("pending-"),
|
||||||
let createOp = await cache.pendingOperations().first(where: { $0.id == merged.id && $0.kind == PendingOperationKind.createDocument.rawValue }),
|
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(
|
let resolvedCreate = CreateDocumentRequest(
|
||||||
title: merged.title,
|
title: merged.title,
|
||||||
text: merged.text,
|
text: merged.text,
|
||||||
@@ -700,7 +816,7 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
|
|
||||||
private func queueCollectionUpdate(_ request: UpdateCollectionRequest, dueTo error: Error?) async throws -> OutlineCollection {
|
private func queueCollectionUpdate(_ request: UpdateCollectionRequest, dueTo error: Error?) async throws -> OutlineCollection {
|
||||||
guard let baseData = await cache.load(forKey: "collection:\(request.id)"),
|
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))
|
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
|
||||||
}
|
}
|
||||||
let merged = OutlineCollection(
|
let merged = OutlineCollection(
|
||||||
@@ -750,44 +866,84 @@ public actor CachingOutlineAPIClient: OutlineAPIClient {
|
|||||||
}
|
}
|
||||||
switch kind {
|
switch kind {
|
||||||
case .createDocument:
|
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)
|
let result = try await live.createDocument(request)
|
||||||
await cacheDocument(result)
|
await cacheDocument(result)
|
||||||
// The placeholder id (== operation.id) is now a dead orphan —
|
// The placeholder id (== operation.id) is now a dead orphan —
|
||||||
// nothing server-side will ever answer to it again.
|
// nothing server-side will ever answer to it again.
|
||||||
await cache.removeCacheEntry(forKey: "document:\(operation.id)")
|
await cache.removeCacheEntry(forKey: "document:\(operation.id)")
|
||||||
case .updateDocument:
|
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)
|
let result = try await live.updateDocument(request)
|
||||||
await cacheDocument(result)
|
await cacheDocument(result)
|
||||||
case .updateCollection:
|
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)
|
let result = try await live.updateCollection(request)
|
||||||
await cacheCollection(result)
|
await cacheCollection(result)
|
||||||
case .createPin:
|
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)
|
_ = try await live.createPin(request)
|
||||||
case .deletePin:
|
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)
|
try await live.deletePin(id: request.id)
|
||||||
case .createSubscription:
|
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)
|
_ = try await live.createSubscription(request)
|
||||||
case .deleteSubscription:
|
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)
|
try await live.deleteSubscription(id: request.id)
|
||||||
case .starDocument:
|
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)
|
_ = try await live.starDocument(request)
|
||||||
case .deleteStar:
|
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)
|
try await live.deleteStar(id: request.id)
|
||||||
case .starCollection:
|
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)
|
_ = 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 {
|
private func errorDescription(_ error: Error) -> String {
|
||||||
if let apiError = error as? OutlineAPIError {
|
if let apiError = error as? OutlineAPIError {
|
||||||
switch apiError {
|
switch apiError {
|
||||||
|
|||||||
@@ -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
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,22 @@ public actor OfflineCacheStore {
|
|||||||
try? modelContext.save()
|
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<CachedPayload>()
|
||||||
|
(try? modelContext.fetch(cachedDescriptor))?.forEach { modelContext.delete($0) }
|
||||||
|
let pendingDescriptor = FetchDescriptor<PendingOperation>()
|
||||||
|
(try? modelContext.fetch(pendingDescriptor))?.forEach { modelContext.delete($0) }
|
||||||
|
try? modelContext.save()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Offline write queue
|
// MARK: - Offline write queue
|
||||||
|
|
||||||
/// Upserts by `id` — a second call with the same id (an edit coalescing
|
/// Upserts by `id` — a second call with the same id (an edit coalescing
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<T: Sendable>(
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
import CryptoKit
|
||||||
@testable import OutlineKit
|
@testable import OutlineKit
|
||||||
|
|
||||||
private struct NotStubbed: Error {}
|
private struct NotStubbed: Error {}
|
||||||
@@ -116,6 +117,22 @@ private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable
|
|||||||
|
|
||||||
private struct StubTransportError: Error {}
|
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 {
|
final class CachingOutlineAPIClientTests: XCTestCase {
|
||||||
private func makeCache() throws -> OfflineCacheStore {
|
private func makeCache() throws -> OfflineCacheStore {
|
||||||
OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true))
|
OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true))
|
||||||
@@ -145,7 +162,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
let document = makeDocument()
|
let document = makeDocument()
|
||||||
stub.documentInfoHandler = { _ in document }
|
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")
|
let result = try await sut.documentInfo(id: "doc-1")
|
||||||
|
|
||||||
@@ -161,7 +178,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
if callCount == 1 { return document }
|
if callCount == 1 { return document }
|
||||||
throw StubTransportError()
|
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.
|
// First call succeeds and populates the cache.
|
||||||
_ = try await sut.documentInfo(id: "doc-1")
|
_ = try await sut.documentInfo(id: "doc-1")
|
||||||
@@ -175,7 +192,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
func testDocumentInfoRethrowsWhenLiveFailsAndCacheIsEmpty() async throws {
|
func testDocumentInfoRethrowsWhenLiveFailsAndCacheIsEmpty() async throws {
|
||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try await sut.documentInfo(id: "doc-1")
|
_ = try await sut.documentInfo(id: "doc-1")
|
||||||
@@ -194,7 +211,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
if callCount == 1 { return collections }
|
if callCount == 1 { return collections }
|
||||||
throw StubTransportError()
|
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)
|
_ = try await sut.listCollections(offset: 0, limit: 25)
|
||||||
let result = 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 docOne = makeDocument(id: "doc-1", title: "One")
|
||||||
let docTwo = makeDocument(id: "doc-2", title: "Two")
|
let docTwo = makeDocument(id: "doc-2", title: "Two")
|
||||||
stub.documentInfoHandler = { id in id == "doc-1" ? docOne : docTwo }
|
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-1")
|
||||||
_ = try await sut.documentInfo(id: "doc-2")
|
_ = try await sut.documentInfo(id: "doc-2")
|
||||||
@@ -227,7 +244,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
let original = makeDocument(id: "doc-1", title: "Original")
|
let original = makeDocument(id: "doc-1", title: "Original")
|
||||||
stub.documentInfoHandler = { _ in original }
|
stub.documentInfoHandler = { _ in original }
|
||||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
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).
|
// Populate the cache with the base document first (as a real open would).
|
||||||
_ = try await sut.documentInfo(id: "doc-1")
|
_ = try await sut.documentInfo(id: "doc-1")
|
||||||
@@ -248,7 +265,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
func testUpdateDocumentRethrowsWhenDocumentWasNeverCached() async throws {
|
func testUpdateDocumentRethrowsWhenDocumentWasNeverCached() async throws {
|
||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
do {
|
do {
|
||||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "never-seen", title: "x"))
|
_ = 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")
|
let original = makeDocument(id: "doc-1", title: "Original")
|
||||||
stub.documentInfoHandler = { _ in original }
|
stub.documentInfoHandler = { _ in original }
|
||||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
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.documentInfo(id: "doc-1")
|
||||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "First Edit"))
|
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "First Edit"))
|
||||||
@@ -276,7 +293,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
func testPinThenUnpinBeforeSyncCancelsOutWithoutQueuingADelete() async throws {
|
func testPinThenUnpinBeforeSyncCancelsOutWithoutQueuingADelete() async throws {
|
||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
stub.createPinHandler = { _ in throw StubTransportError() }
|
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"))
|
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
|
||||||
XCTAssertTrue(pin.id.hasPrefix("pending-"))
|
XCTAssertTrue(pin.id.hasPrefix("pending-"))
|
||||||
@@ -294,7 +311,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
let original = makeDocument(id: "doc-1", title: "Original")
|
let original = makeDocument(id: "doc-1", title: "Original")
|
||||||
stub.documentInfoHandler = { _ in original }
|
stub.documentInfoHandler = { _ in original }
|
||||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
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.documentInfo(id: "doc-1")
|
||||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
|
_ = 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")
|
let original = makeDocument(id: "doc-1", title: "Original")
|
||||||
stub.documentInfoHandler = { _ in original }
|
stub.documentInfoHandler = { _ in original }
|
||||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
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.documentInfo(id: "doc-1")
|
||||||
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
|
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
|
||||||
@@ -345,7 +362,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
liveCallCount += 1
|
liveCallCount += 1
|
||||||
return OutlinePin(id: "real-id", documentId: "doc-1", collectionId: nil, index: nil)
|
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"))
|
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
|
||||||
|
|
||||||
@@ -370,7 +387,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
return [cachedCollection]
|
return [cachedCollection]
|
||||||
}
|
}
|
||||||
let cache = try makeCache()
|
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.
|
// Online first — populates the cache normally.
|
||||||
let firstResult = try await sut.listCollections(offset: 0, limit: 25)
|
let firstResult = try await sut.listCollections(offset: 0, limit: 25)
|
||||||
@@ -389,7 +406,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
func testCacheStorageSummaryReflectsCachedItems() async throws {
|
func testCacheStorageSummaryReflectsCachedItems() async throws {
|
||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
stub.documentInfoHandler = { _ in self.makeDocument() }
|
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")
|
_ = try await sut.documentInfo(id: "doc-1")
|
||||||
let summary = await sut.cacheStorageSummary()
|
let summary = await sut.cacheStorageSummary()
|
||||||
@@ -418,7 +435,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
return (0..<count).map { self.makeCollection(id: "col-\(offset + $0)") }
|
return (0..<count).map { self.makeCollection(id: "col-\(offset + $0)") }
|
||||||
}
|
}
|
||||||
stub.listDocumentsHandler = { _, _, _, _ in [] }
|
stub.listDocumentsHandler = { _, _, _, _ in [] }
|
||||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
let summary = await sut.performFullSync()
|
let summary = await sut.performFullSync()
|
||||||
|
|
||||||
@@ -439,7 +456,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
return offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : []
|
return offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : []
|
||||||
}
|
}
|
||||||
let cache = try makeCache()
|
let cache = try makeCache()
|
||||||
let sut = CachingOutlineAPIClient(live: stub, cache: cache)
|
let sut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
let summary = await sut.performFullSync()
|
let summary = await sut.performFullSync()
|
||||||
XCTAssertEqual(summary.documentsCount, 2)
|
XCTAssertEqual(summary.documentsCount, 2)
|
||||||
@@ -469,7 +486,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let cache = try makeCache()
|
let cache = try makeCache()
|
||||||
let sut = CachingOutlineAPIClient(live: stub, cache: cache)
|
let sut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
let summary = await sut.performFullSync()
|
let summary = await sut.performFullSync()
|
||||||
|
|
||||||
@@ -484,7 +501,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
|
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
|
||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
||||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
let created = try await sut.createDocument(
|
let created = try await sut.createDocument(
|
||||||
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
|
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
|
||||||
@@ -508,7 +525,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
||||||
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
let created = try await sut.createDocument(
|
let created = try await sut.createDocument(
|
||||||
CreateDocumentRequest(title: "Untitled", text: "", collectionId: "col-1")
|
CreateDocumentRequest(title: "Untitled", text: "", collectionId: "col-1")
|
||||||
@@ -528,7 +545,7 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
func testFlushingAPendingCreateReconcilesThePlaceholderIdToTheRealOne() async throws {
|
func testFlushingAPendingCreateReconcilesThePlaceholderIdToTheRealOne() async throws {
|
||||||
let stub = StubOutlineAPIClient()
|
let stub = StubOutlineAPIClient()
|
||||||
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
stub.createDocumentHandler = { _ in throw StubTransportError() }
|
||||||
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
let created = try await sut.createDocument(
|
let created = try await sut.createDocument(
|
||||||
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
|
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
|
||||||
@@ -553,4 +570,169 @@ final class CachingOutlineAPIClientTests: XCTestCase {
|
|||||||
// expected — nothing left under the old id
|
// expected — nothing left under the old id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Repeated-failure tracking
|
||||||
|
|
||||||
|
/// A structural failure (decode/auth/server) isn't retried — it fails
|
||||||
|
/// the same way every time, so `RetryPolicy` gives up after one attempt
|
||||||
|
/// and it's logged immediately. No cache entry means no fallback either,
|
||||||
|
/// so every call rethrows.
|
||||||
|
func testRepeatedDecodingFailuresSurfaceAfterThreshold() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
for _ in 0..<3 {
|
||||||
|
_ = try? await sut.documentInfo(id: "doc-1")
|
||||||
|
}
|
||||||
|
|
||||||
|
let summaries = await sut.repeatedFailureSummaries()
|
||||||
|
XCTAssertEqual(summaries.count, 1)
|
||||||
|
XCTAssertEqual(summaries.first?.category, "document")
|
||||||
|
XCTAssertEqual(summaries.first?.count, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two failures alone shouldn't trip the banner — only three or more
|
||||||
|
/// within the window counts as "repeated."
|
||||||
|
func testFewerThanThresholdFailuresDoNotSurface() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
for _ in 0..<2 {
|
||||||
|
_ = try? await sut.documentInfo(id: "doc-1")
|
||||||
|
}
|
||||||
|
|
||||||
|
let summaries = await sut.repeatedFailureSummaries()
|
||||||
|
XCTAssertTrue(summaries.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A later success clears the category entirely — a transient run of
|
||||||
|
/// bad luck shouldn't leave a stale banner up after things recover.
|
||||||
|
func testSuccessAfterRepeatedFailuresClearsTheLog() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
let document = makeDocument()
|
||||||
|
var callCount = 0
|
||||||
|
stub.documentInfoHandler = { _ in
|
||||||
|
callCount += 1
|
||||||
|
if callCount <= 3 { throw OutlineAPIError.decoding(NotStubbed()) }
|
||||||
|
return document
|
||||||
|
}
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
for _ in 0..<3 {
|
||||||
|
_ = try? await sut.documentInfo(id: "doc-1")
|
||||||
|
}
|
||||||
|
let beforeRecovery = await sut.repeatedFailureSummaries()
|
||||||
|
XCTAssertEqual(beforeRecovery.count, 1)
|
||||||
|
|
||||||
|
_ = try await sut.documentInfo(id: "doc-1")
|
||||||
|
|
||||||
|
let afterRecovery = await sut.repeatedFailureSummaries()
|
||||||
|
XCTAssertTrue(afterRecovery.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plain connectivity loss (`OutlineAPIError.transport`) already has its
|
||||||
|
/// own offline UI elsewhere — it shouldn't also pile up in the repeated-
|
||||||
|
/// failure log and pop a second, redundant banner.
|
||||||
|
func testTransportFailuresDoNotCountTowardTheRepeatedFailureLog() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
stub.documentInfoHandler = { _ in throw OutlineAPIError.transport(URLError(.notConnectedToInternet)) }
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
_ = try? await sut.documentInfo(id: "doc-1")
|
||||||
|
|
||||||
|
let summaries = await sut.repeatedFailureSummaries()
|
||||||
|
XCTAssertTrue(summaries.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Different categories (document reads vs. pin writes) track
|
||||||
|
/// independently — a broken pins endpoint shouldn't mask, or be masked
|
||||||
|
/// by, unrelated document failures, and one crossing the threshold
|
||||||
|
/// shouldn't drag an unrelated one along with it.
|
||||||
|
func testFailuresInDifferentCategoriesDoNotMix() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
stub.documentInfoHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
|
||||||
|
stub.createPinHandler = { _ in throw OutlineAPIError.decoding(NotStubbed()) }
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
// Document reads cross the threshold...
|
||||||
|
for _ in 0..<3 {
|
||||||
|
_ = try? await sut.documentInfo(id: "doc-1")
|
||||||
|
}
|
||||||
|
// ...pin creates don't.
|
||||||
|
for _ in 0..<2 {
|
||||||
|
_ = try? await sut.createPin(CreatePinRequest(documentId: "doc-1", collectionId: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
let summaries = await sut.repeatedFailureSummaries()
|
||||||
|
XCTAssertEqual(summaries.map(\.category), ["document"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - At-rest encryption
|
||||||
|
|
||||||
|
func testCachedPayloadIsNotStoredAsPlaintextJSON() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
let document = makeDocument(title: "Secret Title")
|
||||||
|
stub.documentInfoHandler = { _ in document }
|
||||||
|
let cache = try makeCache()
|
||||||
|
let sut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: StaticCacheEncryptionKeyStore())
|
||||||
|
|
||||||
|
_ = try await sut.documentInfo(id: "doc-1")
|
||||||
|
|
||||||
|
let raw = await cache.load(forKey: "document:doc-1")
|
||||||
|
XCTAssertNotNil(raw)
|
||||||
|
// A plain JSON encode would contain the literal title text in the
|
||||||
|
// clear - ciphertext shouldn't, and shouldn't even parse as JSON.
|
||||||
|
XCTAssertNil(String(data: raw!, encoding: .utf8)?.range(of: "Secret Title"))
|
||||||
|
XCTAssertThrowsError(try JSONDecoder().decode(OutlineDocument.self, from: raw!))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simulates sign-out (key cleared) followed by a fresh sign-in (a new
|
||||||
|
/// `CachingOutlineAPIClient` instance, same underlying on-disk cache,
|
||||||
|
/// same shape as `SessionStore.makeAPIClient` always creating a new
|
||||||
|
/// instance) — anything still on disk from before is unreadable under
|
||||||
|
/// the new key, which is the entire point of clearing it on sign-out.
|
||||||
|
func testCacheIsUnreadableAfterTheEncryptionKeyIsCleared() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
stub.documentInfoHandler = { _ in self.makeDocument() }
|
||||||
|
let cache = try makeCache()
|
||||||
|
let keyStore = StaticCacheEncryptionKeyStore()
|
||||||
|
let beforeSignOut = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: keyStore)
|
||||||
|
_ = try await beforeSignOut.documentInfo(id: "doc-1")
|
||||||
|
|
||||||
|
try keyStore.clear()
|
||||||
|
stub.documentInfoHandler = { _ in throw StubTransportError() }
|
||||||
|
let afterSignIn = CachingOutlineAPIClient(live: stub, cache: cache, encryptionKeyStore: keyStore)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try await afterSignIn.documentInfo(id: "doc-1")
|
||||||
|
XCTFail("Expected the now-undecryptable cache entry to be unusable")
|
||||||
|
} catch is StubTransportError {
|
||||||
|
// expected — live fails, and the leftover cache entry can't be
|
||||||
|
// decrypted under the new key either, so there's no fallback.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testClearEverythingForSignOutWipesBothCacheAndPendingQueue() async throws {
|
||||||
|
let stub = StubOutlineAPIClient()
|
||||||
|
stub.documentInfoHandler = { _ in self.makeDocument() }
|
||||||
|
stub.updateDocumentHandler = { _ in throw StubTransportError() }
|
||||||
|
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: "Offline edit"))
|
||||||
|
|
||||||
|
let summaryBefore = await sut.cacheStorageSummary()
|
||||||
|
let pendingBefore = await sut.pendingOperations()
|
||||||
|
XCTAssertGreaterThan(summaryBefore.itemCount, 0)
|
||||||
|
XCTAssertFalse(pendingBefore.isEmpty)
|
||||||
|
|
||||||
|
await sut.clearEverythingForSignOut()
|
||||||
|
|
||||||
|
let summaryAfter = await sut.cacheStorageSummary()
|
||||||
|
let pendingAfter = await sut.pendingOperations()
|
||||||
|
XCTAssertEqual(summaryAfter.itemCount, 0)
|
||||||
|
XCTAssertTrue(pendingAfter.isEmpty)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import OutlineKit
|
||||||
|
|
||||||
|
private struct PlainError: Error {}
|
||||||
|
|
||||||
|
final class RetryPolicyTests: XCTestCase {
|
||||||
|
func testSucceedsOnFirstAttemptWithoutRetrying() async throws {
|
||||||
|
var callCount = 0
|
||||||
|
let result = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) {
|
||||||
|
callCount += 1
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
XCTAssertEqual(result, "ok")
|
||||||
|
XCTAssertEqual(callCount, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRetriesTransportErrorsAndSucceedsOnceItStopsFailing() async throws {
|
||||||
|
var callCount = 0
|
||||||
|
let result = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) { () -> String in
|
||||||
|
callCount += 1
|
||||||
|
if callCount < 3 { throw OutlineAPIError.transport(URLError(.timedOut)) }
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
XCTAssertEqual(result, "ok")
|
||||||
|
XCTAssertEqual(callCount, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGivesUpAfterMaxAttemptsAndRethrowsTheLastError() async throws {
|
||||||
|
var callCount = 0
|
||||||
|
do {
|
||||||
|
_ = try await RetryPolicy.withRetry(maxAttempts: 3, initialDelay: .milliseconds(1)) { () -> String in
|
||||||
|
callCount += 1
|
||||||
|
throw OutlineAPIError.transport(URLError(.timedOut))
|
||||||
|
}
|
||||||
|
XCTFail("Expected the persistent failure to be rethrown")
|
||||||
|
} catch {
|
||||||
|
XCTAssertEqual(callCount, 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A decode failure means the response is structurally wrong — trying
|
||||||
|
/// again gets the exact same wrong response, so it isn't worth the
|
||||||
|
/// cooldown window the way a network blip is.
|
||||||
|
func testDoesNotRetryNonTransportErrors() async throws {
|
||||||
|
var callCount = 0
|
||||||
|
do {
|
||||||
|
_ = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) { () -> String in
|
||||||
|
callCount += 1
|
||||||
|
throw OutlineAPIError.decoding(PlainError())
|
||||||
|
}
|
||||||
|
XCTFail("Expected the decoding error to be rethrown without retrying")
|
||||||
|
} catch {
|
||||||
|
XCTAssertEqual(callCount, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDoesNotRetryErrorsThatAreNotOutlineAPIErrors() async throws {
|
||||||
|
var callCount = 0
|
||||||
|
do {
|
||||||
|
_ = try await RetryPolicy.withRetry(initialDelay: .milliseconds(1)) { () -> String in
|
||||||
|
callCount += 1
|
||||||
|
throw PlainError()
|
||||||
|
}
|
||||||
|
XCTFail("Expected the error to be rethrown without retrying")
|
||||||
|
} catch {
|
||||||
|
XCTAssertEqual(callCount, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@ struct AccountFooter: View {
|
|||||||
@Environment(SessionStore.self) private var session
|
@Environment(SessionStore.self) private var session
|
||||||
@Environment(AppNavigation.self) private var navigation
|
@Environment(AppNavigation.self) private var navigation
|
||||||
@Environment(\.openURL) private var openURL
|
@Environment(\.openURL) private var openURL
|
||||||
@Environment(\.openWindow) private var openWindow
|
|
||||||
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
|
||||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||||
@State private var isMenuPresented = false
|
@State private var isMenuPresented = false
|
||||||
@@ -63,10 +62,6 @@ struct AccountFooter: View {
|
|||||||
|
|
||||||
private var menuContent: some View {
|
private var menuContent: some View {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
menuItem("Keyboard Shortcuts…") { openWindow(id: "keyboard-shortcuts") }
|
|
||||||
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
menuItem("Documentation") { openURL(repositoryURL) }
|
menuItem("Documentation") { openURL(repositoryURL) }
|
||||||
if let apiDocumentationURL {
|
if let apiDocumentationURL {
|
||||||
menuItem("API Documentation") { openURL(apiDocumentationURL) }
|
menuItem("API Documentation") { openURL(apiDocumentationURL) }
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -42,7 +42,15 @@ struct SettingsSidebarList: View {
|
|||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
List(selection: $selection) {
|
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 }
|
let sections = SettingsSection.allCases.filter { $0.category == category }
|
||||||
Section {
|
Section {
|
||||||
ForEach(sections) { section in
|
ForEach(sections) { section in
|
||||||
@@ -91,6 +99,14 @@ struct SettingsSidebarList: View {
|
|||||||
.task(id: isEffectivelyOnline) { await refreshOutlineVersion() }
|
.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 {
|
private var versionFooter: some View {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("Outpost \(OutpostVersion.displayString)")
|
Text("Outpost \(OutpostVersion.displayString)")
|
||||||
@@ -109,7 +125,7 @@ struct SettingsSidebarList: View {
|
|||||||
|
|
||||||
private func refreshOutlineVersion() async {
|
private func refreshOutlineVersion() async {
|
||||||
guard isEffectivelyOnline, let apiClient = session.apiClient else { return }
|
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
|
#endif
|
||||||
|
|||||||
@@ -618,7 +618,7 @@ struct SettingsView: View {
|
|||||||
defer { isDeletingAccount = false }
|
defer { isDeletingAccount = false }
|
||||||
do {
|
do {
|
||||||
try await apiClient.deleteAccount()
|
try await apiClient.deleteAccount()
|
||||||
session.signOut()
|
await session.signOut()
|
||||||
} catch {
|
} catch {
|
||||||
deleteAccountErrorMessage = outlineErrorMessage(error, fallback: "Couldn't delete your account.")
|
deleteAccountErrorMessage = outlineErrorMessage(error, fallback: "Couldn't delete your account.")
|
||||||
}
|
}
|
||||||
@@ -1182,6 +1182,11 @@ struct SettingsView: View {
|
|||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
sectionHeader
|
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) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Toggle("Offline Mode", isOn: $isOfflineModeEnabled)
|
Toggle("Offline Mode", isOn: $isOfflineModeEnabled)
|
||||||
Text("Skip the network entirely and work from what's already been cached. Turn this off to reconnect.")
|
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)
|
.frame(maxWidth: 480, alignment: .leading)
|
||||||
|
|
||||||
Divider()
|
// TODO: Export All Data / Developer Diagnostics / Reset Local
|
||||||
.frame(maxWidth: 480)
|
// 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) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
comingSoonRow("Export All Data")
|
comingSoonRow("Export All Data")
|
||||||
comingSoonRow("Developer Diagnostics")
|
comingSoonRow("Developer Diagnostics")
|
||||||
@@ -1335,6 +1341,10 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
.frame(maxWidth: 480, alignment: .leading)
|
.frame(maxWidth: 480, alignment: .leading)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.frame(maxWidth: 480)
|
||||||
|
*/
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
.frame(maxWidth: 480)
|
.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 {
|
private func comingSoonRow(_ title: String) -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
Text(title)
|
Text(title)
|
||||||
@@ -1503,7 +1516,7 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
private func refreshProfile() async {
|
private func refreshProfile() async {
|
||||||
guard let apiClient = session.apiClient else { return }
|
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)
|
session.applyUpdatedProfile(fresh)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1535,7 +1548,7 @@ struct SettingsView: View {
|
|||||||
// Best-effort — the new avatar is already live either way, this
|
// Best-effort — the new avatar is already live either way, this
|
||||||
// just stops the old upload from sitting around unreferenced.
|
// just stops the old upload from sitting around unreferenced.
|
||||||
if let previousAttachmentId {
|
if let previousAttachmentId {
|
||||||
try? await apiClient.deleteAttachment(id: previousAttachmentId)
|
try? await RetryPolicy.withRetry({ try await apiClient.deleteAttachment(id: previousAttachmentId) })
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
avatarErrorMessage = outlineErrorMessage(error, fallback: "Couldn't upload this photo.")
|
avatarErrorMessage = outlineErrorMessage(error, fallback: "Couldn't upload this photo.")
|
||||||
@@ -1552,7 +1565,7 @@ struct SettingsView: View {
|
|||||||
session.applyUpdatedProfile(updated)
|
session.applyUpdatedProfile(updated)
|
||||||
avatarErrorMessage = nil
|
avatarErrorMessage = nil
|
||||||
if let previousAttachmentId {
|
if let previousAttachmentId {
|
||||||
try? await apiClient.deleteAttachment(id: previousAttachmentId)
|
try? await RetryPolicy.withRetry({ try await apiClient.deleteAttachment(id: previousAttachmentId) })
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
avatarErrorMessage = outlineErrorMessage(error, fallback: "Couldn't remove this photo.")
|
avatarErrorMessage = outlineErrorMessage(error, fallback: "Couldn't remove this photo.")
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ struct CollectionDocumentsOutline: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadPins() async {
|
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) })
|
pinsByDocumentID = Dictionary(uniqueKeysWithValues: pins.map { ($0.documentId, $0) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ struct DocumentCommentsSheet: View {
|
|||||||
}
|
}
|
||||||
.frame(width: 480, height: 560)
|
.frame(width: 480, height: 560)
|
||||||
.task { await load() }
|
.task { await load() }
|
||||||
.task { currentUserId = try? await apiClient.currentUser().id }
|
.task { currentUserId = try? await RetryPolicy.withRetry({ try await apiClient.currentUser().id }) }
|
||||||
.task { composingAnchorText = pendingAnchorText }
|
.task { composingAnchorText = pendingAnchorText }
|
||||||
.alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) {
|
.alert("Couldn't Complete Action", isPresented: .constant(actionErrorMessage != nil)) {
|
||||||
Button("OK") { actionErrorMessage = nil }
|
Button("OK") { actionErrorMessage = nil }
|
||||||
|
|||||||
@@ -328,9 +328,11 @@ struct DocumentReaderView: View {
|
|||||||
await viewModel.loadInsightsEnabledState()
|
await viewModel.loadInsightsEnabledState()
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
loadedComments = (try? await apiClient.listComments(
|
loadedComments = (try? await RetryPolicy.withRetry({
|
||||||
|
try await apiClient.listComments(
|
||||||
ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true)
|
ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true)
|
||||||
)) ?? []
|
)
|
||||||
|
})) ?? []
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
@@ -387,9 +389,11 @@ struct DocumentReaderView: View {
|
|||||||
pendingAnchorText: pendingCommentAnchorText,
|
pendingAnchorText: pendingCommentAnchorText,
|
||||||
onCommentsChanged: {
|
onCommentsChanged: {
|
||||||
Task {
|
Task {
|
||||||
loadedComments = (try? await apiClient.listComments(
|
loadedComments = (try? await RetryPolicy.withRetry({
|
||||||
|
try await apiClient.listComments(
|
||||||
ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true)
|
ListCommentsRequest(documentId: viewModel.documentId, includeAnchorText: true)
|
||||||
)) ?? loadedComments
|
)
|
||||||
|
})) ?? loadedComments
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -111,14 +111,18 @@ final class DocumentReaderViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadViewers() async {
|
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 }
|
viewers = views.filter { $0.lastViewedAt != nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadPinAndSubscriptionState() async {
|
func loadPinAndSubscriptionState() async {
|
||||||
// `collectionId: nil` = Home pins. This menu's Pin action is "Pin to
|
// `collectionId: nil` = Home pins. This menu's Pin action is "Pin to
|
||||||
// Home", not "Pin to Collection" — those are distinct on the server.
|
// 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 }) {
|
let match = pins.first(where: { $0.documentId == documentId }) {
|
||||||
isPinned = true
|
isPinned = true
|
||||||
pinId = match.id
|
pinId = match.id
|
||||||
@@ -127,7 +131,7 @@ final class DocumentReaderViewModel {
|
|||||||
pinId = nil
|
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 {
|
let match = subscriptions.first {
|
||||||
isSubscribed = true
|
isSubscribed = true
|
||||||
subscriptionId = match.id
|
subscriptionId = match.id
|
||||||
|
|||||||
@@ -370,7 +370,7 @@ struct DocumentShareSheet: View {
|
|||||||
private func loadMembers() async {
|
private func loadMembers() async {
|
||||||
isLoadingMembers = true
|
isLoadingMembers = true
|
||||||
defer { isLoadingMembers = false }
|
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 {
|
private func searchUsers(_ query: String) async {
|
||||||
@@ -381,7 +381,7 @@ struct DocumentShareSheet: View {
|
|||||||
}
|
}
|
||||||
isSearchingUsers = true
|
isSearchingUsers = true
|
||||||
defer { isSearchingUsers = false }
|
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 {
|
private func addUser(_ user: OutlineUser) async {
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ final class HomeViewModel {
|
|||||||
/// set (unlike a full collection tree), so the N+1 here is acceptable
|
/// 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.
|
||||||
private func fetchPinnedThrowing() async throws -> [OutlineDocument] {
|
private func fetchPinnedThrowing() async throws -> [OutlineDocument] {
|
||||||
let pins = try await apiClient.listPins(ListPinsRequest(collectionId: nil))
|
let pins = try await RetryPolicy.withRetry { try await apiClient.listPins(ListPinsRequest(collectionId: nil)) }
|
||||||
var documents: [OutlineDocument] = []
|
var documents: [OutlineDocument] = []
|
||||||
for pin in pins {
|
for pin in pins {
|
||||||
if let document = try? await apiClient.documentInfo(id: pin.documentId) {
|
if let document = try? await apiClient.documentInfo(id: pin.documentId) {
|
||||||
@@ -134,7 +134,7 @@ final class HomeViewModel {
|
|||||||
|
|
||||||
private func resolveCurrentUserID() async throws -> String {
|
private func resolveCurrentUserID() async throws -> String {
|
||||||
if let currentUserID { return currentUserID }
|
if let currentUserID { return currentUserID }
|
||||||
let user = try await apiClient.currentUser()
|
let user = try await RetryPolicy.withRetry { try await apiClient.currentUser() }
|
||||||
currentUserID = user.id
|
currentUserID = user.id
|
||||||
return user.id
|
return user.id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,14 +75,6 @@ struct OutpostApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if os(macOS)
|
|
||||||
Window("Keyboard Shortcuts", id: "keyboard-shortcuts") {
|
|
||||||
KeyboardShortcutsView()
|
|
||||||
.disablesFullScreen()
|
|
||||||
}
|
|
||||||
.windowResizability(.contentSize)
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
<!-- Anything else you can add about what you were doing when this started would help. -->
|
||||||
|
"""
|
||||||
|
components?.queryItems = [URLQueryItem(name: "body", value: body)]
|
||||||
|
return components?.url
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -3,8 +3,10 @@ import OutlineKit
|
|||||||
|
|
||||||
struct RootView: View {
|
struct RootView: View {
|
||||||
@Environment(SessionStore.self) private var session
|
@Environment(SessionStore.self) private var session
|
||||||
|
@Environment(\.openURL) private var openURL
|
||||||
@State private var welcomeName: String?
|
@State private var welcomeName: String?
|
||||||
@State private var starStore = StarStore()
|
@State private var starStore = StarStore()
|
||||||
|
@State private var failureCenter = APIFailureCenter()
|
||||||
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
|
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
|
||||||
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
|
||||||
|
|
||||||
@@ -34,10 +36,39 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.environment(starStore)
|
.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)
|
.animation(.easeInOut(duration: 0.45), value: welcomeName != nil)
|
||||||
.task {
|
.task {
|
||||||
await session.refreshTeamInfoIfNeeded()
|
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) {
|
.task(id: session.isSignedIn) {
|
||||||
if session.isSignedIn, let apiClient = session.apiClient {
|
if session.isSignedIn, let apiClient = session.apiClient {
|
||||||
await starStore.load(apiClient: 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) {
|
private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) {
|
||||||
welcomeName = result.user.name
|
welcomeName = result.user.name
|
||||||
session.signIn(serverURL: result.serverURL, user: result.user, team: result.team)
|
session.signIn(serverURL: result.serverURL, user: result.user, team: result.team)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ final class SessionStore {
|
|||||||
private static let userPreferencesDefaultsKey = "outline.userPreferences"
|
private static let userPreferencesDefaultsKey = "outline.userPreferences"
|
||||||
|
|
||||||
private let tokenStore: TokenStoring
|
private let tokenStore: TokenStoring
|
||||||
|
private let cacheEncryptionKeyStore: CacheEncryptionKeyStoring
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
var isSignedIn: Bool
|
var isSignedIn: Bool
|
||||||
@@ -43,8 +44,13 @@ final class SessionStore {
|
|||||||
defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
|
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.tokenStore = tokenStore
|
||||||
|
self.cacheEncryptionKeyStore = cacheEncryptionKeyStore
|
||||||
self.defaults = defaults
|
self.defaults = defaults
|
||||||
self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
|
self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
|
||||||
|
|
||||||
@@ -53,7 +59,12 @@ final class SessionStore {
|
|||||||
|
|
||||||
if hasToken, let storedServerURL {
|
if hasToken, let storedServerURL {
|
||||||
isSignedIn = true
|
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)
|
userPreferences = Self.loadCachedPreferences(defaults: defaults)
|
||||||
} else {
|
} else {
|
||||||
// Keychain and the sandboxed UserDefaults container don't
|
// Keychain and the sandboxed UserDefaults container don't
|
||||||
@@ -73,7 +84,12 @@ final class SessionStore {
|
|||||||
|
|
||||||
func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) {
|
func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) {
|
||||||
defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey)
|
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)
|
apply(user: user, team: team, serverURL: serverURL)
|
||||||
isSignedIn = true
|
isSignedIn = true
|
||||||
}
|
}
|
||||||
@@ -99,6 +115,7 @@ final class SessionStore {
|
|||||||
private static func makeAPIClient(
|
private static func makeAPIClient(
|
||||||
serverURL: URL,
|
serverURL: URL,
|
||||||
tokenStore: TokenStoring,
|
tokenStore: TokenStoring,
|
||||||
|
cacheEncryptionKeyStore: CacheEncryptionKeyStoring,
|
||||||
cache: OfflineCacheStore?
|
cache: OfflineCacheStore?
|
||||||
) -> (OutlineAPIClient, CachingOutlineAPIClient?) {
|
) -> (OutlineAPIClient, CachingOutlineAPIClient?) {
|
||||||
let live = LiveOutlineAPIClient(
|
let live = LiveOutlineAPIClient(
|
||||||
@@ -106,12 +123,22 @@ final class SessionStore {
|
|||||||
tokenStore: tokenStore
|
tokenStore: tokenStore
|
||||||
)
|
)
|
||||||
guard let cache else { return (live, nil) }
|
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)
|
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()
|
try? tokenStore.clear()
|
||||||
|
await cachingClient?.clearEverythingForSignOut()
|
||||||
|
try? cacheEncryptionKeyStore.clear()
|
||||||
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
|
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
|
||||||
isSignedIn = false
|
isSignedIn = false
|
||||||
userId = nil
|
userId = nil
|
||||||
@@ -136,7 +163,7 @@ final class SessionStore {
|
|||||||
/// in-memory state didn't.
|
/// in-memory state didn't.
|
||||||
func refreshTeamInfoIfNeeded() async {
|
func refreshTeamInfoIfNeeded() async {
|
||||||
guard isSignedIn, teamName == nil, let apiClient, let serverURL else { return }
|
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)
|
apply(user: auth.user, team: auth.team, serverURL: serverURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ final class StarStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func load(apiClient: OutlineAPIClient) async {
|
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
|
documentStars = Dictionary(uniqueKeysWithValues: stars.compactMap { star in
|
||||||
star.documentId.map { ($0, star) }
|
star.documentId.map { ($0, star) }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ extension View {
|
|||||||
titleVisibility: .visible
|
titleVisibility: .visible
|
||||||
) {
|
) {
|
||||||
Button("Log Out", role: .destructive) {
|
Button("Log Out", role: .destructive) {
|
||||||
session.signOut()
|
Task { await session.signOut() }
|
||||||
}
|
}
|
||||||
Button("Cancel", role: .cancel) {}
|
Button("Cancel", role: .cancel) {}
|
||||||
} message: {
|
} message: {
|
||||||
|
|||||||
@@ -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
|
|
||||||
+4
-2
@@ -9,7 +9,9 @@
|
|||||||
// which costs grow with file size instead of staying constant. The whole point:
|
// 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.
|
// 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.
|
// Debug-only — the whole thing compiles out in Release.
|
||||||
// Remove before shipping (this file + the `PerfTrace.` call sites).
|
// Remove before shipping (this file + the `PerfTrace.` call sites).
|
||||||
//
|
//
|
||||||
@@ -18,7 +20,7 @@ import Foundation
|
|||||||
|
|
||||||
enum PerfTrace {
|
enum PerfTrace {
|
||||||
#if DEBUG
|
#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,
|
/// Opt-in for the sampled full-rebuild verifier asserts (wiki splice,
|
||||||
/// backtick census, parse buffer). They run 3× O(doc) work synchronously
|
/// backtick census, parse buffer). They run 3× O(doc) work synchronously
|
||||||
/// on every 64th keystroke — periodic spikes that pollute the PERF
|
/// on every 64th keystroke — periodic spikes that pollute the PERF
|
||||||
|
|||||||
+3
-3
@@ -15,7 +15,7 @@ import AppKit
|
|||||||
extension NativeTextViewCoordinator {
|
extension NativeTextViewCoordinator {
|
||||||
func updateCodeBlockSelection(textView: NSTextView, parsed: ParsedDocument? = nil) {
|
func updateCodeBlockSelection(textView: NSTextView, parsed: ParsedDocument? = nil) {
|
||||||
guard let textContainer = textView.textContainer else {
|
guard let textContainer = textView.textContainer else {
|
||||||
onCodeBlockSelectionChange?([])
|
fireCodeBlockSelectionChange([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ extension NativeTextViewCoordinator {
|
|||||||
// no per-call full-token filter.
|
// no per-call full-token filter.
|
||||||
cachedCodeBlockTokens = parsed.codeBlockTokensWithIndices
|
cachedCodeBlockTokens = parsed.codeBlockTokensWithIndices
|
||||||
} else if cachedCodeBlockTokens.isEmpty {
|
} else if cachedCodeBlockTokens.isEmpty {
|
||||||
onCodeBlockSelectionChange?([])
|
fireCodeBlockSelectionChange([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,6 +91,6 @@ extension NativeTextViewCoordinator {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
onCodeBlockSelectionChange?(selections)
|
fireCodeBlockSelectionChange(selections)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -16,7 +16,7 @@ import AppKit
|
|||||||
extension NativeTextViewCoordinator {
|
extension NativeTextViewCoordinator {
|
||||||
func updateCommentAnchorRects(textView: NSTextView) {
|
func updateCommentAnchorRects(textView: NSTextView) {
|
||||||
guard !commentAnchorQueries.isEmpty else {
|
guard !commentAnchorQueries.isEmpty else {
|
||||||
onCommentAnchorRectsChange?([])
|
fireCommentAnchorRectsChange([])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let nsText = textView.string as NSString
|
let nsText = textView.string as NSString
|
||||||
@@ -28,6 +28,6 @@ extension NativeTextViewCoordinator {
|
|||||||
let rect = textView.viewRect(forCharacterRange: found, using: layoutBridge) else { continue }
|
let rect = textView.viewRect(forCharacterRange: found, using: layoutBridge) else { continue }
|
||||||
results.append(CommentAnchorRect(id: query.id, rect: rect))
|
results.append(CommentAnchorRect(id: query.id, rect: rect))
|
||||||
}
|
}
|
||||||
onCommentAnchorRectsChange?(results)
|
fireCommentAnchorRectsChange(results)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -362,7 +362,7 @@ extension NativeTextViewCoordinator {
|
|||||||
// selection at all.
|
// selection at all.
|
||||||
if !isRebuildingDocument {
|
if !isRebuildingDocument {
|
||||||
let selRange = tv.selectedRange()
|
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.
|
// Raw mode: plain source — no reveal, snap-back, or inline previews.
|
||||||
if configuration.rawSourceMode { return }
|
if configuration.rawSourceMode { return }
|
||||||
|
|||||||
+20
@@ -87,6 +87,26 @@ public final class NativeTextViewCoordinator: NSObject, NSTextViewDelegate {
|
|||||||
var onSelectedTextChange: ((String?) -> Void)?
|
var onSelectedTextChange: ((String?) -> Void)?
|
||||||
var commentAnchorQueries: [CommentAnchorQuery] = []
|
var commentAnchorQueries: [CommentAnchorQuery] = []
|
||||||
var onCommentAnchorRectsChange: (([CommentAnchorRect]) -> Void)?
|
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
|
var didInitialFormatting: Bool = false
|
||||||
/// One-shot guard so `updateCodeBlockSelection` only forces a full-document layout once per document.
|
/// One-shot guard so `updateCodeBlockSelection` only forces a full-document layout once per document.
|
||||||
var didEnsureLayoutForCurrentDocument: Bool = false
|
var didEnsureLayoutForCurrentDocument: Bool = false
|
||||||
|
|||||||
Reference in New Issue
Block a user