From eee00197bb0f56b04cc722ce371cdb9b18e2c4ff Mon Sep 17 00:00:00 2001 From: psavarmattas Date: Fri, 14 Aug 2026 23:41:00 +0100 Subject: [PATCH] feat(offline): read-through cache for browse endpoints + offline badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CachingOutlineAPIClient (OutlineKit) wraps LiveOutlineAPIClient at the existing protocol boundary: always tries live, falls back to a SwiftData key/value cache only on failure, and overwrites the cache on every live success. Applies to the read/browse path only (collections, document lists, document content) — search, writes, and sharing pass straight through uncached. Wired in once at SessionStore.makeAPIClient, so no view model needed to change. 5 new tests via a stub OutlineAPIClient (43/43 passing). NetworkMonitor (NWPathMonitor-backed, app target) is unrelated to the cache's own fallback logic — it only drives a new sidebar OfflineBanner so the user knows when they might be looking at stale content. --- .../OutlineKit/Caching/CachedPayload.swift | 18 ++ .../Caching/CachingOutlineAPIClient.swift | 260 ++++++++++++++++++ .../Caching/OfflineCacheStore.swift | 29 ++ .../CachingOutlineAPIClientTests.swift | 177 ++++++++++++ .../Collections/ContentView_macOS.swift | 4 + .../Features/Collections/OfflineBanner.swift | 21 ++ Outpost/Root/SessionStore.swift | 23 +- Outpost/Support/NetworkMonitor.swift | 29 ++ 8 files changed, 554 insertions(+), 7 deletions(-) create mode 100644 OutlineKit/Sources/OutlineKit/Caching/CachedPayload.swift create mode 100644 OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift create mode 100644 OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift create mode 100644 OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift create mode 100644 Outpost/Features/Collections/OfflineBanner.swift create mode 100644 Outpost/Support/NetworkMonitor.swift diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachedPayload.swift b/OutlineKit/Sources/OutlineKit/Caching/CachedPayload.swift new file mode 100644 index 0000000..5c6e8ce --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Caching/CachedPayload.swift @@ -0,0 +1,18 @@ +import Foundation +import SwiftData + +/// Generic key/value row backing `OfflineCacheStore` — one table for every +/// cached response shape instead of a `@Model` per Outline type, so adding a +/// new cached endpoint never needs a schema migration. +@Model +public final class CachedPayload { + @Attribute(.unique) public var key: String + public var payload: Data + public var cachedAt: Date + + public init(key: String, payload: Data, cachedAt: Date) { + self.key = key + self.payload = payload + self.cachedAt = cachedAt + } +} diff --git a/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift new file mode 100644 index 0000000..fb33445 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Caching/CachingOutlineAPIClient.swift @@ -0,0 +1,260 @@ +import Foundation + +/// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with a +/// read-through cache for the handful of endpoints "browse the workspace +/// offline" actually needs: the collection tree, document lists, and a +/// document's own content. Every other call — search, writes, sharing, +/// pins, etc. — passes straight through, since those either don't make +/// sense offline or aren't worth the staleness risk. +/// +/// Deliberately doesn't ask "are we online?" up front — that's a separate +/// question from "did this specific request fail?", and checking first can +/// be wrong the instant connectivity flaps. Instead: always try live, and +/// fall back to the cache only on failure. A successful live response +/// always overwrites the cache, so it can never get more stale than the +/// last time this actually worked. +public actor CachingOutlineAPIClient: OutlineAPIClient { + private let live: OutlineAPIClient + private let cache: OfflineCacheStore + private let encoder: JSONEncoder + private let decoder: JSONDecoder + private let keyEncoder: JSONEncoder + + public init(live: OutlineAPIClient, cache: OfflineCacheStore) { + self.live = live + self.cache = cache + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + self.encoder = encoder + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + self.decoder = decoder + + let keyEncoder = JSONEncoder() + keyEncoder.outputFormatting = .sortedKeys + self.keyEncoder = keyEncoder + } + + // MARK: - Cached reads + + public func documentInfo(id: String) async throws -> OutlineDocument { + try await cachedFetch(key: "document:\(id)") { try await self.live.documentInfo(id: id) } + } + + public func listDocuments( + collectionId: String?, + parentDocumentId: String?, + offset: Int, + limit: Int + ) async throws -> [OutlineDocument] { + let key = "documents:\(collectionId ?? "-"):\(parentDocumentId ?? "-"):\(offset):\(limit)" + return try await cachedFetch(key: key) { + try await self.live.listDocuments( + collectionId: collectionId, + parentDocumentId: parentDocumentId, + offset: offset, + limit: limit + ) + } + } + + public func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { + try await cachedFetch(key: requestKey("documentsList", request)) { try await self.live.documentsList(request) } + } + + public func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { + try await cachedFetch(key: "documentsViewed:\(offset):\(limit)") { + try await self.live.listViewedDocuments(offset: offset, limit: limit) + } + } + + public func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] { + try await cachedFetch(key: "collections:\(offset):\(limit)") { + try await self.live.listCollections(offset: offset, limit: limit) + } + } + + public func collectionInfo(id: String) async throws -> OutlineCollection { + try await cachedFetch(key: "collection:\(id)") { try await self.live.collectionInfo(id: id) } + } + + // MARK: - Pass-through + + public func authInfo() async throws -> OutlineAuthInfo { + try await live.authInfo() + } + + public func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { + try await live.searchDocuments(request) + } + + public func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { + try await live.searchDocumentTitles(request) + } + + public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument { + try await live.createDocument(request) + } + + public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument { + try await live.updateDocument(request) + } + + public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar { + try await live.starDocument(request) + } + + public func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate { + try await live.templatizeDocument(request) + } + + public func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument] { + try await live.duplicateDocument(request) + } + + public func unpublishDocument(_ request: UnpublishDocumentRequest) async throws -> OutlineDocument { + try await live.unpublishDocument(request) + } + + public func archiveDocument(id: String) async throws -> OutlineDocument { + try await live.archiveDocument(id: id) + } + + public func moveDocument(_ request: MoveDocumentRequest) async throws { + try await live.moveDocument(request) + } + + public func deleteDocument(_ request: DeleteDocumentRequest) async throws { + try await live.deleteDocument(request) + } + + public func documentInsights(_ request: DocumentInsightsRequest) async throws -> [OutlineDocumentInsight] { + try await live.documentInsights(request) + } + + public func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision] { + try await live.listRevisions(request) + } + + public func exportDocument(id: String) async throws -> String { + try await live.exportDocument(id: id) + } + + public func createShare(_ request: CreateShareRequest) async throws -> OutlineShare { + try await live.createShare(request) + } + + public func shareInfo(documentId: String) async throws -> OutlineShare? { + try await live.shareInfo(documentId: documentId) + } + + public func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare { + try await live.updateShare(request) + } + + public func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] { + try await live.listShares(request) + } + + public func revokeShare(id: String) async throws { + try await live.revokeShare(id: id) + } + + public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin { + try await live.createPin(request) + } + + public func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] { + try await live.listPins(request) + } + + public func deletePin(id: String) async throws { + try await live.deletePin(id: id) + } + + public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription { + try await live.createSubscription(request) + } + + public func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] { + try await live.listSubscriptions(request) + } + + public func deleteSubscription(id: String) async throws { + try await live.deleteSubscription(id: id) + } + + public func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] { + try await live.listViews(request) + } + + public func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership { + try await live.addDocumentUser(request) + } + + public func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws { + try await live.removeDocumentUser(request) + } + + public func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] { + try await live.documentUsers(request) + } + + public func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser] { + try await live.listUsers(request) + } + + public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection { + try await live.updateCollection(request) + } + + public func deleteCollection(id: String) async throws { + try await live.deleteCollection(id: id) + } + + public func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation { + try await live.exportCollection(request) + } + + public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar { + try await live.starCollection(request) + } + + public func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] { + try await live.listStars(request) + } + + public func deleteStar(id: String) async throws { + try await live.deleteStar(id: id) + } + + public func currentUser() async throws -> OutlineUser { + try await live.currentUser() + } + + // MARK: - Helpers + + private func cachedFetch(key: String, fetch: () async throws -> T) async throws -> T { + do { + let result = try await fetch() + if let data = try? encoder.encode(result) { + await cache.save(data, forKey: key) + } + return result + } catch { + if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) { + return cached + } + throw error + } + } + + private func requestKey(_ prefix: String, _ request: some Encodable) -> String { + guard let data = try? keyEncoder.encode(request), let json = String(data: data, encoding: .utf8) else { + return prefix + } + return "\(prefix):\(json)" + } +} diff --git a/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift b/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift new file mode 100644 index 0000000..3ebf693 --- /dev/null +++ b/OutlineKit/Sources/OutlineKit/Caching/OfflineCacheStore.swift @@ -0,0 +1,29 @@ +import Foundation +import SwiftData + +/// Read-through cache backing `CachingOutlineAPIClient` — stores whatever it +/// last fetched successfully, keyed by request shape, so browsing keeps +/// working (stale) once the network stops. +@ModelActor +public actor OfflineCacheStore { + public static func makeContainer(inMemory: Bool = false) throws -> ModelContainer { + let configuration = ModelConfiguration(isStoredInMemoryOnly: inMemory) + return try ModelContainer(for: CachedPayload.self, configurations: configuration) + } + + public func save(_ data: Data, forKey key: String) { + let descriptor = FetchDescriptor(predicate: #Predicate { $0.key == key }) + if let existing = try? modelContext.fetch(descriptor).first { + existing.payload = data + existing.cachedAt = Date() + } else { + modelContext.insert(CachedPayload(key: key, payload: data, cachedAt: Date())) + } + try? modelContext.save() + } + + public func load(forKey key: String) -> Data? { + let descriptor = FetchDescriptor(predicate: #Predicate { $0.key == key }) + return try? modelContext.fetch(descriptor).first?.payload + } +} diff --git a/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift new file mode 100644 index 0000000..d96e613 --- /dev/null +++ b/OutlineKit/Tests/OutlineKitTests/CachingOutlineAPIClientTests.swift @@ -0,0 +1,177 @@ +import XCTest +@testable import OutlineKit + +private struct NotStubbed: Error {} + +/// Conforms to the full protocol (so it can stand in for `live`), but only +/// the two methods under test in this file have real behavior — everything +/// else throws loudly if a test accidentally exercises it. +private final class StubOutlineAPIClient: OutlineAPIClient, @unchecked Sendable { + var documentInfoHandler: (@Sendable (String) async throws -> OutlineDocument)? + var listCollectionsHandler: (@Sendable (Int, Int) async throws -> [OutlineCollection])? + + func authInfo() async throws -> OutlineAuthInfo { throw NotStubbed() } + + func documentInfo(id: String) async throws -> OutlineDocument { + guard let handler = documentInfoHandler else { throw NotStubbed() } + return try await handler(id) + } + + func listDocuments(collectionId: String?, parentDocumentId: String?, offset: Int, limit: Int) async throws -> [OutlineDocument] { + throw NotStubbed() + } + + func documentsList(_ request: DocumentsListRequest) async throws -> [OutlineDocument] { throw NotStubbed() } + func listViewedDocuments(offset: Int, limit: Int) async throws -> [OutlineDocument] { throw NotStubbed() } + func searchDocuments(_ request: DocumentSearchRequest) async throws -> [OutlineDocumentSearchResult] { throw NotStubbed() } + func searchDocumentTitles(_ request: DocumentSearchTitlesRequest) async throws -> [OutlineDocument] { throw NotStubbed() } + func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument { throw NotStubbed() } + func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument { throw NotStubbed() } + func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar { throw NotStubbed() } + func templatizeDocument(_ request: TemplatizeDocumentRequest) async throws -> OutlineTemplate { throw NotStubbed() } + func duplicateDocument(_ request: DuplicateDocumentRequest) async throws -> [OutlineDocument] { throw NotStubbed() } + func unpublishDocument(_ request: UnpublishDocumentRequest) async throws -> OutlineDocument { throw NotStubbed() } + func archiveDocument(id: String) async throws -> OutlineDocument { throw NotStubbed() } + func moveDocument(_ request: MoveDocumentRequest) async throws { throw NotStubbed() } + func deleteDocument(_ request: DeleteDocumentRequest) async throws { throw NotStubbed() } + func documentInsights(_ request: DocumentInsightsRequest) async throws -> [OutlineDocumentInsight] { throw NotStubbed() } + func listRevisions(_ request: ListRevisionsRequest) async throws -> [OutlineRevision] { throw NotStubbed() } + func exportDocument(id: String) async throws -> String { throw NotStubbed() } + func createShare(_ request: CreateShareRequest) async throws -> OutlineShare { throw NotStubbed() } + func shareInfo(documentId: String) async throws -> OutlineShare? { throw NotStubbed() } + func updateShare(_ request: UpdateShareRequest) async throws -> OutlineShare { throw NotStubbed() } + func listShares(_ request: ListSharesRequest) async throws -> [OutlineShare] { throw NotStubbed() } + func revokeShare(id: String) async throws { throw NotStubbed() } + func createPin(_ request: CreatePinRequest) async throws -> OutlinePin { throw NotStubbed() } + func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] { throw NotStubbed() } + func deletePin(id: String) async throws { throw NotStubbed() } + func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription { throw NotStubbed() } + func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] { throw NotStubbed() } + func deleteSubscription(id: String) async throws { throw NotStubbed() } + func listViews(_ request: ListViewsRequest) async throws -> [OutlineView] { throw NotStubbed() } + func addDocumentUser(_ request: AddDocumentUserRequest) async throws -> OutlineMembership { throw NotStubbed() } + func removeDocumentUser(_ request: RemoveDocumentUserRequest) async throws { throw NotStubbed() } + func documentUsers(_ request: ListDocumentUsersRequest) async throws -> [OutlineDocumentMember] { throw NotStubbed() } + func listUsers(_ request: ListUsersRequest) async throws -> [OutlineUser] { throw NotStubbed() } + + func listCollections(offset: Int, limit: Int) async throws -> [OutlineCollection] { + guard let handler = listCollectionsHandler else { throw NotStubbed() } + return try await handler(offset, limit) + } + + func collectionInfo(id: String) async throws -> OutlineCollection { throw NotStubbed() } + func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection { throw NotStubbed() } + func deleteCollection(id: String) async throws { throw NotStubbed() } + func exportCollection(_ request: ExportCollectionRequest) async throws -> OutlineFileOperation { throw NotStubbed() } + func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar { throw NotStubbed() } + func listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] { throw NotStubbed() } + func deleteStar(id: String) async throws { throw NotStubbed() } + func currentUser() async throws -> OutlineUser { throw NotStubbed() } +} + +private struct StubTransportError: Error {} + +final class CachingOutlineAPIClientTests: XCTestCase { + private func makeCache() throws -> OfflineCacheStore { + OfflineCacheStore(modelContainer: try OfflineCacheStore.makeContainer(inMemory: true)) + } + + private func makeDocument(id: String = "doc-1", title: String = "Hello") -> OutlineDocument { + OutlineDocument( + id: id, + title: title, + text: "body", + url: "/doc/\(id)", + createdAt: Date(timeIntervalSince1970: 0), + updatedAt: Date(timeIntervalSince1970: 0) + ) + } + + private func makeCollection(id: String = "col-1", name: String = "Engineering") -> OutlineCollection { + OutlineCollection( + id: id, + name: name, + createdAt: Date(timeIntervalSince1970: 0), + updatedAt: Date(timeIntervalSince1970: 0) + ) + } + + func testDocumentInfoWritesThroughToCacheOnSuccess() async throws { + let stub = StubOutlineAPIClient() + let document = makeDocument() + stub.documentInfoHandler = { _ in document } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + let result = try await sut.documentInfo(id: "doc-1") + + XCTAssertEqual(result, document) + } + + func testDocumentInfoFallsBackToCacheWhenLiveFails() async throws { + let stub = StubOutlineAPIClient() + let document = makeDocument() + var callCount = 0 + stub.documentInfoHandler = { _ in + callCount += 1 + if callCount == 1 { return document } + throw StubTransportError() + } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + // First call succeeds and populates the cache. + _ = try await sut.documentInfo(id: "doc-1") + // Second call fails live — should transparently return the cached value. + let result = try await sut.documentInfo(id: "doc-1") + + XCTAssertEqual(result, document) + XCTAssertEqual(callCount, 2) + } + + func testDocumentInfoRethrowsWhenLiveFailsAndCacheIsEmpty() async throws { + let stub = StubOutlineAPIClient() + stub.documentInfoHandler = { _ in throw StubTransportError() } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + do { + _ = try await sut.documentInfo(id: "doc-1") + XCTFail("Expected an error") + } catch is StubTransportError { + // expected + } + } + + func testListCollectionsFallsBackToCacheWhenLiveFails() async throws { + let stub = StubOutlineAPIClient() + let collections = [makeCollection()] + var callCount = 0 + stub.listCollectionsHandler = { _, _ in + callCount += 1 + if callCount == 1 { return collections } + throw StubTransportError() + } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + _ = try await sut.listCollections(offset: 0, limit: 25) + let result = try await sut.listCollections(offset: 0, limit: 25) + + XCTAssertEqual(result, collections) + } + + func testDifferentDocumentIdsAreCachedSeparately() async throws { + let stub = StubOutlineAPIClient() + let docOne = makeDocument(id: "doc-1", title: "One") + let docTwo = makeDocument(id: "doc-2", title: "Two") + stub.documentInfoHandler = { id in id == "doc-1" ? docOne : docTwo } + let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache()) + + _ = try await sut.documentInfo(id: "doc-1") + _ = try await sut.documentInfo(id: "doc-2") + stub.documentInfoHandler = { _ in throw StubTransportError() } + + let cachedOne = try await sut.documentInfo(id: "doc-1") + let cachedTwo = try await sut.documentInfo(id: "doc-2") + + XCTAssertEqual(cachedOne, docOne) + XCTAssertEqual(cachedTwo, docTwo) + } +} diff --git a/Outpost/Features/Collections/ContentView_macOS.swift b/Outpost/Features/Collections/ContentView_macOS.swift index 3a7d250..01dc410 100644 --- a/Outpost/Features/Collections/ContentView_macOS.swift +++ b/Outpost/Features/Collections/ContentView_macOS.swift @@ -31,6 +31,10 @@ struct ContentView_macOS: View { VStack(spacing: 0) { SidebarSearchField(text: $globalSearchQuery) Divider() + if !session.networkMonitor.isOnline { + OfflineBanner() + Divider() + } sidebar AccountFooter() } diff --git a/Outpost/Features/Collections/OfflineBanner.swift b/Outpost/Features/Collections/OfflineBanner.swift new file mode 100644 index 0000000..8229c52 --- /dev/null +++ b/Outpost/Features/Collections/OfflineBanner.swift @@ -0,0 +1,21 @@ +#if os(macOS) +import SwiftUI + +/// Shown at the top of the sidebar whenever `NetworkMonitor` reports no +/// connection — cached collections/documents keep browsing working, but the +/// user should know they might be looking at stale data. +struct OfflineBanner: View { + var body: some View { + HStack(spacing: 8) { + Image(systemName: "wifi.slash") + .foregroundStyle(.orange) + Text("Offline — showing cached content") + .font(.callout) + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color.orange.opacity(0.12)) + } +} +#endif diff --git a/Outpost/Root/SessionStore.swift b/Outpost/Root/SessionStore.swift index d8563fe..b2af143 100644 --- a/Outpost/Root/SessionStore.swift +++ b/Outpost/Root/SessionStore.swift @@ -17,6 +17,11 @@ final class SessionStore { var teamName: String? var teamAvatarURL: URL? private(set) var apiClient: OutlineAPIClient? + let networkMonitor = NetworkMonitor() + /// `nil` only if the on-disk SwiftData store failed to open (e.g. disk + /// full) — in that case `apiClient` falls back to talking to the server + /// directly with no offline cache, rather than failing sign-in outright. + private let cacheStore: OfflineCacheStore? var serverURL: URL? { defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:)) @@ -26,23 +31,27 @@ final class SessionStore { self.tokenStore = tokenStore self.defaults = defaults self.isSignedIn = (try? tokenStore.token()) != nil + self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:)) if isSignedIn, let serverURL { - apiClient = LiveOutlineAPIClient( - configuration: OutlineConfiguration(baseURL: serverURL), - tokenStore: tokenStore - ) + apiClient = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore) } } func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) { defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey) - apiClient = LiveOutlineAPIClient( + apiClient = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore) + apply(user: user, team: team, serverURL: serverURL) + isSignedIn = true + } + + private static func makeAPIClient(serverURL: URL, tokenStore: TokenStoring, cache: OfflineCacheStore?) -> OutlineAPIClient { + let live = LiveOutlineAPIClient( configuration: OutlineConfiguration(baseURL: serverURL), tokenStore: tokenStore ) - apply(user: user, team: team, serverURL: serverURL) - isSignedIn = true + guard let cache else { return live } + return CachingOutlineAPIClient(live: live, cache: cache) } func signOut() { diff --git a/Outpost/Support/NetworkMonitor.swift b/Outpost/Support/NetworkMonitor.swift new file mode 100644 index 0000000..4c2fd5b --- /dev/null +++ b/Outpost/Support/NetworkMonitor.swift @@ -0,0 +1,29 @@ +import Foundation +import Network +import Observation + +/// Backs the sidebar's offline badge — purely a UI signal. Unrelated to +/// `CachingOutlineAPIClient`'s own fallback logic, which reacts to actual +/// request failures rather than pre-checking reachability. +@Observable +@MainActor +final class NetworkMonitor { + private(set) var isOnline = true + + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "com.outpost.network-monitor") + + init() { + monitor.pathUpdateHandler = { [weak self] path in + let online = path.status == .satisfied + Task { @MainActor in + self?.isOnline = online + } + } + monitor.start(queue: queue) + } + + deinit { + monitor.cancel() + } +}