Merge pull request 'Offline-first support: read cache, write queue, settings redesign' (#6) from feature/offline-cache into main

Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
2026-08-15 15:47:48 +01:00
committed by PSM Git
30 changed files with 2211 additions and 78 deletions
@@ -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
}
}
@@ -0,0 +1,665 @@
import Foundation
/// Decorates `LiveOutlineAPIClient` (or any `OutlineAPIClient`) with offline
/// support at the existing protocol boundary, so no view model needs to know
/// the network exists:
///
/// - **Reads** (`documentInfo`, `listDocuments`, `documentsList`,
/// `listViewedDocuments`, `listCollections`, `collectionInfo`): read-through
/// cache. Always tries live first never pre-checks reachability, since
/// "did this specific request just fail" is a more honest signal than a
/// reachability monitor, and the two can disagree (captive portals,
/// flaky Wi-Fi). Falls back to the cache only on failure; a live success
/// always overwrites the cache, so staleness is bounded by "last time this
/// endpoint actually worked."
/// - **A small set of writes** (`updateDocument`, `updateCollection`,
/// pin/star/subscribe create+delete): applied optimistically against the
/// cache and queued in `OfflineCacheStore`'s `PendingOperation` table on
/// failure, then replayed in order by `flushPendingOperations()` once back
/// online. Deliberately doesn't include anything that creates new tree
/// structure (`createDocument`, `moveDocument`, `archiveDocument`,
/// `deleteDocument`, `deleteCollection`, `duplicateDocument`) those need
/// real server-assigned ids to stay consistent with the rest of the tree,
/// and reconciling a locally-invented id with the one the server hands
/// back on sync is a much bigger problem than this pass takes on. Sharing,
/// permissions, search, and export also stay live-only read the request,
/// they need someone else's server session, not just a network.
/// - **Manual offline mode** (`offlineModeDefaultsKey`): when set, skips
/// attempting `live` entirely same fallback/queue paths as a real
/// failure, just chosen on purpose instead of discovered.
public actor CachingOutlineAPIClient: OutlineAPIClient {
public static let offlineModeDefaultsKey = "outpost.offlineModeEnabled"
private let live: OutlineAPIClient
private let cache: OfflineCacheStore
private let defaults: UserDefaults
private let encoder: JSONEncoder
private let decoder: JSONDecoder
private let keyEncoder: JSONEncoder
public init(live: OutlineAPIClient, cache: OfflineCacheStore, defaults: UserDefaults = .standard) {
self.live = live
self.cache = cache
self.defaults = defaults
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
}
private var isManualOfflineModeEnabled: Bool {
defaults.bool(forKey: Self.offlineModeDefaultsKey)
}
// 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: - Queueable writes
/// The one exception to "no new tree structure while offline"
/// synthesizes a document under a `pending-<uuid>` id (same scheme as
/// pin/star/subscribe), caches it so it's immediately readable/editable,
/// and queues the real create. On sync, the server-assigned id replaces
/// the placeholder in the cache; anything still referencing the old id
/// directly (a currently-open reader, most likely) won't follow that
/// rename automatically a known, narrow limitation, not something this
/// pass tries to solve generally.
public func createDocument(_ request: CreateDocumentRequest) async throws -> OutlineDocument {
if !isManualOfflineModeEnabled {
do {
let result = try await live.createDocument(request)
await cacheDocument(result)
return result
} catch {
return await queueDocumentCreate(request)
}
}
return await queueDocumentCreate(request)
}
public func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
if !isManualOfflineModeEnabled {
do {
let result = try await live.updateDocument(request)
await cacheDocument(result)
return result
} catch {
return try await queueDocumentUpdate(request, dueTo: error)
}
}
return try await queueDocumentUpdate(request, dueTo: nil)
}
public func updateCollection(_ request: UpdateCollectionRequest) async throws -> OutlineCollection {
if !isManualOfflineModeEnabled {
do {
let result = try await live.updateCollection(request)
await cacheCollection(result)
return result
} catch {
return try await queueCollectionUpdate(request, dueTo: error)
}
}
return try await queueCollectionUpdate(request, dueTo: nil)
}
public func createPin(_ request: CreatePinRequest) async throws -> OutlinePin {
if !isManualOfflineModeEnabled {
do { return try await live.createPin(request) } catch { return await queuePinCreate(request) }
}
return await queuePinCreate(request)
}
public func deletePin(id: String) async throws {
if await cancelIfNeverSynced(id: id) { return }
if !isManualOfflineModeEnabled {
do {
try await live.deletePin(id: id)
return
} catch {
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
return
}
}
await enqueue(.deletePin, payload: IDPayload(id: id), id: "delete-pin-\(id)")
}
public func createSubscription(_ request: CreateSubscriptionRequest) async throws -> OutlineSubscription {
if !isManualOfflineModeEnabled {
do { return try await live.createSubscription(request) } catch { return await queueSubscriptionCreate(request) }
}
return await queueSubscriptionCreate(request)
}
public func deleteSubscription(id: String) async throws {
if await cancelIfNeverSynced(id: id) { return }
if !isManualOfflineModeEnabled {
do {
try await live.deleteSubscription(id: id)
return
} catch {
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
return
}
}
await enqueue(.deleteSubscription, payload: IDPayload(id: id), id: "delete-subscription-\(id)")
}
public func starDocument(_ request: StarDocumentRequest) async throws -> OutlineStar {
if !isManualOfflineModeEnabled {
do { return try await live.starDocument(request) } catch { return await queueStarDocumentCreate(request) }
}
return await queueStarDocumentCreate(request)
}
public func starCollection(_ request: StarCollectionRequest) async throws -> OutlineStar {
if !isManualOfflineModeEnabled {
do { return try await live.starCollection(request) } catch { return await queueStarCollectionCreate(request) }
}
return await queueStarCollectionCreate(request)
}
public func deleteStar(id: String) async throws {
if await cancelIfNeverSynced(id: id) { return }
if !isManualOfflineModeEnabled {
do {
try await live.deleteStar(id: id)
return
} catch {
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(id)")
return
}
}
await enqueue(.deleteStar, payload: IDPayload(id: id), id: "delete-star-\(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 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 listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] {
try await live.listPins(request)
}
public func listSubscriptions(_ request: ListSubscriptionsRequest) async throws -> [OutlineSubscription] {
try await live.listSubscriptions(request)
}
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 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 listStars(_ request: ListStarsRequest) async throws -> [OutlineStar] {
try await live.listStars(request)
}
public func currentUser() async throws -> OutlineUser {
try await live.currentUser()
}
// MARK: - Sync management (Settings surface)
public func pendingOperations() async -> [PendingOperationSummary] {
await cache.pendingOperations().map {
PendingOperationSummary(id: $0.id, kind: $0.kind, createdAt: $0.createdAt, attemptCount: $0.attemptCount, lastError: $0.lastError)
}
}
public func cacheStorageSummary() async -> CacheStorageSummary {
CacheStorageSummary(itemCount: await cache.itemCount(), totalBytes: await cache.totalBytes())
}
public func clearCache() async {
await cache.clearAll()
}
/// Replays every queued operation against `live`, in the order they were
/// queued. Each is independent one failing doesn't block the rest.
public func flushPendingOperations() async -> SyncFlushSummary {
let operations = await cache.pendingOperations()
var succeeded = 0
var failed = 0
for operation in operations {
do {
try await replay(operation)
await cache.removeOperation(id: operation.id)
succeeded += 1
} catch {
await cache.recordFailure(id: operation.id, error: errorDescription(error))
failed += 1
}
}
return SyncFlushSummary(succeeded: succeeded, failed: failed)
}
/// "Full Local Sync": eagerly walks every collection and caches every
/// document's full content (not just whatever's been opened), so
/// browsing offline works for the whole workspace, not only what was
/// already viewed. Goes through `self`, not `live`, directly the
/// existing cached-read methods already do the caching as a side effect,
/// this just has to drive the walk and separately cache each document by
/// id (`listDocuments`'s cache key is the list, not the individual doc).
public func performFullSync() async -> FullSyncSummary {
var documentsCount = 0
var errors: [String] = []
var collections: [OutlineCollection] = []
var collectionsOffset = 0
let collectionsLimit = 100
// Outline rejects any `limit` over 100 outright page in increments
// of that instead of guessing a total up front (the protocol doesn't
// expose `pagination`'s total count, only the page itself); a page
// shorter than the limit is what signals "that was the last one".
while true {
let page: [OutlineCollection]
do {
page = try await listCollections(offset: collectionsOffset, limit: collectionsLimit)
} catch {
errors.append(errorDescription(error))
break
}
collections.append(contentsOf: page)
guard page.count == collectionsLimit else { break }
collectionsOffset += collectionsLimit
}
for collection in collections {
var offset = 0
let limit = 100
while true {
let documents: [OutlineDocument]
do {
documents = try await listDocuments(collectionId: collection.id, parentDocumentId: nil, offset: offset, limit: limit)
} catch {
errors.append("\(collection.name): \(errorDescription(error))")
break
}
for document in documents {
await cacheDocument(document)
}
documentsCount += documents.count
guard documents.count == limit else { break }
offset += limit
}
}
return FullSyncSummary(collectionsCount: collections.count, documentsCount: documentsCount, errors: errors, finishedAt: Date())
}
// MARK: - Helpers
private func cachedFetch<T: Codable>(key: String, fetch: () async throws -> T) async throws -> T {
// Manual offline mode means "skip the network entirely," not just
// "prefer it" without this check, a read would still hit `live`
// (and succeed, showing content beyond whatever's cached) any time
// the device actually had a connection, defeating the point of
// deliberately testing/working as if offline.
if isManualOfflineModeEnabled {
if let data = await cache.load(forKey: key), let cached = try? decoder.decode(T.self, from: data) {
return cached
}
throw OutlineAPIError.transport(URLError(.notConnectedToInternet))
}
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)"
}
private func cacheDocument(_ document: OutlineDocument) async {
if let data = try? encoder.encode(document) {
await cache.save(data, forKey: "document:\(document.id)")
}
}
private func cacheCollection(_ collection: OutlineCollection) async {
if let data = try? encoder.encode(collection) {
await cache.save(data, forKey: "collection:\(collection.id)")
}
}
private func enqueue(_ kind: PendingOperationKind, payload: some Encodable, id: String) async {
guard let data = try? encoder.encode(payload) else { return }
await cache.enqueueOperation(id: id, kind: kind.rawValue, payload: data)
}
/// A delete targeting a `pending-*` id can only mean "cancel the create
/// still sitting in the queue" the server has never heard of that id,
/// so queuing the delete would just fail once synced. Returns whether it
/// found (and removed) a matching create, meaning the caller is done.
private func cancelIfNeverSynced(id: String) async -> Bool {
guard id.hasPrefix("pending-") else { return false }
return await cache.removeOperation(id: id)
}
private func queueDocumentCreate(_ request: CreateDocumentRequest) async -> OutlineDocument {
let pendingId = "pending-\(UUID().uuidString)"
let now = Date()
let synthesized = OutlineDocument(
id: pendingId,
title: request.title,
text: request.text,
emoji: nil,
collectionId: request.collectionId,
parentDocumentId: request.parentDocumentId,
url: "/doc/\(pendingId)",
revision: nil,
fullWidth: nil,
createdAt: now,
updatedAt: now,
publishedAt: request.publish ? now : nil,
archivedAt: nil,
deletedAt: nil
)
await cacheDocument(synthesized)
await enqueue(.createDocument, payload: request, id: pendingId)
return synthesized
}
private func queueDocumentUpdate(_ request: UpdateDocumentRequest, dueTo error: Error?) async throws -> OutlineDocument {
guard let baseData = await cache.load(forKey: "document:\(request.id)"),
let base = try? decoder.decode(OutlineDocument.self, from: baseData) else {
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
}
let mergedText = request.append == true ? base.text + (request.text ?? "") : (request.text ?? base.text)
let merged = OutlineDocument(
id: base.id,
title: request.title ?? base.title,
text: mergedText,
emoji: base.emoji,
collectionId: base.collectionId,
parentDocumentId: base.parentDocumentId,
url: base.url,
revision: base.revision,
fullWidth: request.fullWidth ?? base.fullWidth,
createdAt: base.createdAt,
updatedAt: Date(),
publishedAt: base.publishedAt,
archivedAt: base.archivedAt,
deletedAt: base.deletedAt
)
await cacheDocument(merged)
// Still-unsynced create for this exact document fold the edit into
// the pending create's payload instead of queuing a separate update.
// A separate update would target `merged.id`, which is still the
// `pending-*` placeholder at replay time; the server has never heard
// of it and the update would just fail every retry.
if merged.id.hasPrefix("pending-"),
let createOp = await cache.pendingOperations().first(where: { $0.id == merged.id && $0.kind == PendingOperationKind.createDocument.rawValue }),
let createRequest = try? decoder.decode(CreateDocumentRequest.self, from: createOp.payload) {
let resolvedCreate = CreateDocumentRequest(
title: merged.title,
text: merged.text,
collectionId: createRequest.collectionId,
parentDocumentId: createRequest.parentDocumentId,
publish: createRequest.publish
)
await enqueue(.createDocument, payload: resolvedCreate, id: merged.id)
return merged
}
// Fully resolved (not the original partial request) so replaying just
// this one queued operation reproduces `merged` exactly that's what
// makes coalescing a second edit onto the same queued id safe.
let resolved = UpdateDocumentRequest(id: merged.id, title: merged.title, text: merged.text, fullWidth: merged.fullWidth)
await enqueue(.updateDocument, payload: resolved, id: "update-document-\(merged.id)")
return merged
}
private func queueCollectionUpdate(_ request: UpdateCollectionRequest, dueTo error: Error?) async throws -> OutlineCollection {
guard let baseData = await cache.load(forKey: "collection:\(request.id)"),
let base = try? decoder.decode(OutlineCollection.self, from: baseData) else {
throw error ?? OutlineAPIError.transport(URLError(.notConnectedToInternet))
}
let merged = OutlineCollection(
id: base.id,
name: request.name ?? base.name,
description: request.description ?? base.description,
color: base.color,
icon: base.icon,
createdAt: base.createdAt,
updatedAt: Date()
)
await cacheCollection(merged)
let resolved = UpdateCollectionRequest(id: merged.id, name: merged.name, description: merged.description)
await enqueue(.updateCollection, payload: resolved, id: "update-collection-\(merged.id)")
return merged
}
private func queuePinCreate(_ request: CreatePinRequest) async -> OutlinePin {
let pendingId = "pending-\(UUID().uuidString)"
await enqueue(.createPin, payload: request, id: pendingId)
return OutlinePin(id: pendingId, documentId: request.documentId, collectionId: request.collectionId, index: nil)
}
private func queueSubscriptionCreate(_ request: CreateSubscriptionRequest) async -> OutlineSubscription {
let pendingId = "pending-\(UUID().uuidString)"
await enqueue(.createSubscription, payload: request, id: pendingId)
return OutlineSubscription(id: pendingId, documentId: request.documentId, collectionId: nil, event: request.event)
}
private func queueStarDocumentCreate(_ request: StarDocumentRequest) async -> OutlineStar {
let pendingId = "pending-\(UUID().uuidString)"
await enqueue(.starDocument, payload: request, id: pendingId)
return OutlineStar(id: pendingId, index: nil, documentId: request.documentId, collectionId: nil)
}
private func queueStarCollectionCreate(_ request: StarCollectionRequest) async -> OutlineStar {
let pendingId = "pending-\(UUID().uuidString)"
await enqueue(.starCollection, payload: request, id: pendingId)
return OutlineStar(id: pendingId, index: nil, documentId: nil, collectionId: request.collectionId)
}
private func replay(_ operation: PendingOperation) async throws {
guard let kind = PendingOperationKind(rawValue: operation.kind) else {
throw OutlineAPIError.decoding(DecodingError.dataCorrupted(
DecodingError.Context(codingPath: [], debugDescription: "Unknown pending operation kind: \(operation.kind)")
))
}
switch kind {
case .createDocument:
let request = try decoder.decode(CreateDocumentRequest.self, from: operation.payload)
let result = try await live.createDocument(request)
await cacheDocument(result)
// The placeholder id (== operation.id) is now a dead orphan
// nothing server-side will ever answer to it again.
await cache.removeCacheEntry(forKey: "document:\(operation.id)")
case .updateDocument:
let request = try decoder.decode(UpdateDocumentRequest.self, from: operation.payload)
let result = try await live.updateDocument(request)
await cacheDocument(result)
case .updateCollection:
let request = try decoder.decode(UpdateCollectionRequest.self, from: operation.payload)
let result = try await live.updateCollection(request)
await cacheCollection(result)
case .createPin:
let request = try decoder.decode(CreatePinRequest.self, from: operation.payload)
_ = try await live.createPin(request)
case .deletePin:
let request = try decoder.decode(IDPayload.self, from: operation.payload)
try await live.deletePin(id: request.id)
case .createSubscription:
let request = try decoder.decode(CreateSubscriptionRequest.self, from: operation.payload)
_ = try await live.createSubscription(request)
case .deleteSubscription:
let request = try decoder.decode(IDPayload.self, from: operation.payload)
try await live.deleteSubscription(id: request.id)
case .starDocument:
let request = try decoder.decode(StarDocumentRequest.self, from: operation.payload)
_ = try await live.starDocument(request)
case .deleteStar:
let request = try decoder.decode(IDPayload.self, from: operation.payload)
try await live.deleteStar(id: request.id)
case .starCollection:
let request = try decoder.decode(StarCollectionRequest.self, from: operation.payload)
_ = try await live.starCollection(request)
}
}
private func errorDescription(_ error: Error) -> String {
if let apiError = error as? OutlineAPIError {
switch apiError {
case .unauthorized: return "Sign-in expired."
case .notFound: return "Not found on the server."
case .server(let status, let message): return message ?? "Server error (\(status))."
case .decoding: return "Unexpected response shape."
case .transport: return "Couldn't reach the server."
case .tokenUnavailable: return "Couldn't access the saved sign-in."
}
}
return String(describing: error)
}
}
@@ -0,0 +1,104 @@
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. Also owns the offline write
/// queue (`PendingOperation`) same store, same actor, since both need
/// synchronized access to the same on-disk SwiftData container.
@ModelActor
public actor OfflineCacheStore {
public static func makeContainer(inMemory: Bool = false) throws -> ModelContainer {
let configuration = ModelConfiguration(isStoredInMemoryOnly: inMemory)
return try ModelContainer(for: CachedPayload.self, PendingOperation.self, configurations: configuration)
}
// MARK: - Read-through cache
public func save(_ data: Data, forKey key: String) {
let descriptor = FetchDescriptor<CachedPayload>(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<CachedPayload>(predicate: #Predicate { $0.key == key })
return try? modelContext.fetch(descriptor).first?.payload
}
/// Used to drop a temporary `pending-*` document's cache entry once a
/// queued create syncs and the server hands back the real id the
/// placeholder key would otherwise sit around as a dead orphan forever.
public func removeCacheEntry(forKey key: String) {
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
guard let existing = try? modelContext.fetch(descriptor).first else { return }
modelContext.delete(existing)
try? modelContext.save()
}
public func itemCount() -> Int {
(try? modelContext.fetchCount(FetchDescriptor<CachedPayload>())) ?? 0
}
public func totalBytes() -> Int {
let descriptor = FetchDescriptor<CachedPayload>()
let rows = (try? modelContext.fetch(descriptor)) ?? []
return rows.reduce(0) { $0 + $1.payload.count }
}
public func clearAll() {
let descriptor = FetchDescriptor<CachedPayload>()
guard let rows = try? modelContext.fetch(descriptor) else { return }
rows.forEach { modelContext.delete($0) }
try? modelContext.save()
}
// MARK: - Offline write queue
/// Upserts by `id` a second call with the same id (an edit coalescing
/// onto a still-unsynced edit, or a create being cancelled by its own
/// synthesized id) replaces the row in place rather than piling up.
public func enqueueOperation(id: String, kind: String, payload: Data) {
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
if let existing = try? modelContext.fetch(descriptor).first {
existing.kind = kind
existing.payload = payload
existing.lastError = nil
existing.attemptCount = 0
} else {
modelContext.insert(PendingOperation(id: id, kind: kind, payload: payload, createdAt: Date()))
}
try? modelContext.save()
}
public func pendingOperations() -> [PendingOperation] {
let descriptor = FetchDescriptor<PendingOperation>(sortBy: [SortDescriptor(\.createdAt)])
return (try? modelContext.fetch(descriptor)) ?? []
}
/// Returns whether a matching operation was actually found and removed
/// callers use this to detect "this targeted something that never made
/// it to the server in the first place" and skip queuing a delete.
@discardableResult
public func removeOperation(id: String) -> Bool {
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
guard let existing = try? modelContext.fetch(descriptor).first else { return false }
modelContext.delete(existing)
try? modelContext.save()
return true
}
public func recordFailure(id: String, error: String) {
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
guard let existing = try? modelContext.fetch(descriptor).first else { return }
existing.lastAttemptAt = Date()
existing.lastError = error
existing.attemptCount += 1
try? modelContext.save()
}
}
@@ -0,0 +1,40 @@
import Foundation
import SwiftData
/// A queued mutation made while offline, waiting to replay against the live
/// server. `id` is deliberately overloaded: for actions that create a new
/// server-side record (pin, star, subscription), it's also the synthesized
/// placeholder id handed back to the caller immediately so a matching
/// delete queued before that create ever syncs can cancel both out by id
/// instead of hitting a server that's never heard of the placeholder. For
/// actions that edit an existing record (document/collection updates), it's
/// deterministic per target id, so a second edit before the first syncs
/// coalesces into one queued operation instead of piling up.
@Model
public final class PendingOperation {
@Attribute(.unique) public var id: String
public var kind: String
public var payload: Data
public var createdAt: Date
public var lastAttemptAt: Date?
public var lastError: String?
public var attemptCount: Int
public init(
id: String,
kind: String,
payload: Data,
createdAt: Date,
lastAttemptAt: Date? = nil,
lastError: String? = nil,
attemptCount: Int = 0
) {
self.id = id
self.kind = kind
self.payload = payload
self.createdAt = createdAt
self.lastAttemptAt = lastAttemptAt
self.lastError = lastError
self.attemptCount = attemptCount
}
}
@@ -0,0 +1,50 @@
import Foundation
/// UI-facing snapshot of a queued offline mutation deliberately not the
/// `@Model` type itself, so Settings can display it without holding a
/// reference into the SwiftData store.
public struct PendingOperationSummary: Identifiable, Sendable {
public let id: String
public let kind: String
public let createdAt: Date
public let attemptCount: Int
public let lastError: String?
}
public struct CacheStorageSummary: Sendable {
public let itemCount: Int
public let totalBytes: Int
}
public struct SyncFlushSummary: Sendable {
public let succeeded: Int
public let failed: Int
}
public struct FullSyncSummary: Sendable {
public let collectionsCount: Int
public let documentsCount: Int
public let errors: [String]
public let finishedAt: Date
}
/// Every mutation `CachingOutlineAPIClient` knows how to queue offline and
/// replay later. Deliberately a small, explicit set see its doc comment
/// for what's excluded and why.
enum PendingOperationKind: String, Codable, Sendable {
case createDocument
case updateDocument
case updateCollection
case createPin
case deletePin
case createSubscription
case deleteSubscription
case starDocument
case deleteStar
case starCollection
}
/// Shared payload shape for the delete-by-id queueable operations.
struct IDPayload: Codable, Sendable {
let id: String
}
@@ -1,6 +1,6 @@
import Foundation import Foundation
public struct CreateDocumentRequest: Encodable, Sendable { public struct CreateDocumentRequest: Codable, Sendable {
public let title: String public let title: String
public let text: String public let text: String
public let collectionId: String public let collectionId: String
@@ -2,7 +2,7 @@ import Foundation
/// See `OutlinePin`. `collectionId: nil` = "Pin to Home", non-nil = "Pin to /// See `OutlinePin`. `collectionId: nil` = "Pin to Home", non-nil = "Pin to
/// Collection" these are distinct actions on the real server. /// Collection" these are distinct actions on the real server.
public struct CreatePinRequest: Encodable, Sendable { public struct CreatePinRequest: Codable, Sendable {
public let documentId: String public let documentId: String
public let collectionId: String? public let collectionId: String?
@@ -1,7 +1,7 @@
import Foundation import Foundation
/// See `OutlineSubscription` best-effort shape, not in the vendored spec. /// See `OutlineSubscription` best-effort shape, not in the vendored spec.
public struct CreateSubscriptionRequest: Encodable, Sendable { public struct CreateSubscriptionRequest: Codable, Sendable {
public let documentId: String public let documentId: String
public let event: String public let event: String
@@ -1,6 +1,6 @@
import Foundation import Foundation
public struct StarCollectionRequest: Encodable, Sendable { public struct StarCollectionRequest: Codable, Sendable {
public let collectionId: String public let collectionId: String
public init(collectionId: String) { public init(collectionId: String) {
@@ -1,6 +1,6 @@
import Foundation import Foundation
public struct StarDocumentRequest: Encodable, Sendable { public struct StarDocumentRequest: Codable, Sendable {
public let documentId: String public let documentId: String
public init(documentId: String) { public init(documentId: String) {
@@ -1,6 +1,6 @@
import Foundation import Foundation
public struct UpdateCollectionRequest: Encodable, Sendable { public struct UpdateCollectionRequest: Codable, Sendable {
public let id: String public let id: String
public let name: String? public let name: String?
public let description: String? public let description: String?
@@ -1,6 +1,6 @@
import Foundation import Foundation
public struct UpdateDocumentRequest: Encodable, Sendable { public struct UpdateDocumentRequest: Codable, Sendable {
public let id: String public let id: String
public let title: String? public let title: String?
public let text: String? public let text: String?
@@ -0,0 +1,500 @@
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])?
var updateDocumentHandler: (@Sendable (UpdateDocumentRequest) async throws -> OutlineDocument)?
var createPinHandler: (@Sendable (CreatePinRequest) async throws -> OutlinePin)?
var deletePinHandler: (@Sendable (String) async throws -> Void)?
var listDocumentsHandler: (@Sendable (String?, String?, Int, Int) async throws -> [OutlineDocument])?
var createDocumentHandler: (@Sendable (CreateDocumentRequest) async throws -> OutlineDocument)?
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] {
guard let handler = listDocumentsHandler else { throw NotStubbed() }
return try await handler(collectionId, parentDocumentId, offset, limit)
}
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 {
guard let handler = createDocumentHandler else { throw NotStubbed() }
return try await handler(request)
}
func updateDocument(_ request: UpdateDocumentRequest) async throws -> OutlineDocument {
guard let handler = updateDocumentHandler else { throw NotStubbed() }
return try await handler(request)
}
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 {
guard let handler = createPinHandler else { throw NotStubbed() }
return try await handler(request)
}
func listPins(_ request: ListPinsRequest) async throws -> [OutlinePin] { throw NotStubbed() }
func deletePin(id: String) async throws {
guard let handler = deletePinHandler else { throw NotStubbed() }
try await handler(id)
}
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)
}
// MARK: - Offline writes
func testUpdateDocumentQueuesAndAppliesOptimisticallyWhenLiveFails() async throws {
let stub = StubOutlineAPIClient()
let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
// Populate the cache with the base document first (as a real open would).
_ = try await sut.documentInfo(id: "doc-1")
let updated = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
XCTAssertEqual(updated.title, "Edited Offline")
// Re-opening the document (still offline) should reflect the optimistic edit.
stub.documentInfoHandler = { _ in throw StubTransportError() }
let reopened = try await sut.documentInfo(id: "doc-1")
XCTAssertEqual(reopened.title, "Edited Offline")
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1)
XCTAssertEqual(pending.first?.kind, "updateDocument")
}
func testUpdateDocumentRethrowsWhenDocumentWasNeverCached() async throws {
let stub = StubOutlineAPIClient()
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
do {
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "never-seen", title: "x"))
XCTFail("Expected an error")
} catch is StubTransportError {
// expected nothing to optimistically merge onto
}
}
func testSecondOfflineEditCoalescesIntoOneQueuedOperation() async throws {
let stub = StubOutlineAPIClient()
let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
_ = 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: "Second Edit"))
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1)
}
func testPinThenUnpinBeforeSyncCancelsOutWithoutQueuingADelete() async throws {
let stub = StubOutlineAPIClient()
stub.createPinHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
XCTAssertTrue(pin.id.hasPrefix("pending-"))
let pendingCount1 = await sut.pendingOperations().count
XCTAssertEqual(pendingCount1, 1)
try await sut.deletePin(id: pin.id)
let pendingCount0 = await sut.pendingOperations().count
XCTAssertEqual(pendingCount0, 0)
}
func testFlushPendingOperationsReplaysAndClearsOnSuccess() async throws {
let stub = StubOutlineAPIClient()
let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
_ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
let pendingCount1 = await sut.pendingOperations().count
XCTAssertEqual(pendingCount1, 1)
// Network's back replay should now succeed.
stub.updateDocumentHandler = { request in
self.makeDocument(id: request.id, title: request.title ?? "")
}
let summary = await sut.flushPendingOperations()
XCTAssertEqual(summary.succeeded, 1)
XCTAssertEqual(summary.failed, 0)
let pendingCount0 = await sut.pendingOperations().count
XCTAssertEqual(pendingCount0, 0)
}
func testFlushPendingOperationsRecordsErrorAndKeepsFailedOperation() async throws {
let stub = StubOutlineAPIClient()
let original = makeDocument(id: "doc-1", title: "Original")
stub.documentInfoHandler = { _ in original }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
_ = try await sut.documentInfo(id: "doc-1")
_ = try await sut.updateDocument(UpdateDocumentRequest(id: "doc-1", title: "Edited Offline"))
// Still offline replay should fail and keep the operation queued.
let summary = await sut.flushPendingOperations()
XCTAssertEqual(summary.succeeded, 0)
XCTAssertEqual(summary.failed, 1)
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1)
XCTAssertEqual(pending.first?.attemptCount, 1)
XCTAssertNotNil(pending.first?.lastError)
}
func testManualOfflineModeSkipsLiveEntirely() async throws {
let defaults = UserDefaults(suiteName: "CachingOutlineAPIClientTests.manualOffline")!
defaults.removePersistentDomain(forName: "CachingOutlineAPIClientTests.manualOffline")
defaults.set(true, forKey: CachingOutlineAPIClient.offlineModeDefaultsKey)
let stub = StubOutlineAPIClient()
var liveCallCount = 0
stub.createPinHandler = { _ in
liveCallCount += 1
return OutlinePin(id: "real-id", documentId: "doc-1", collectionId: nil, index: nil)
}
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache(), defaults: defaults)
let pin = try await sut.createPin(CreatePinRequest(documentId: "doc-1"))
XCTAssertEqual(liveCallCount, 0, "Manual offline mode should never attempt the live call")
XCTAssertTrue(pin.id.hasPrefix("pending-"))
}
/// Regression test: manual offline mode used to only gate writes
/// reads (listCollections, documentInfo, etc.) still hit `live` first
/// any time the device actually had a connection, silently defeating
/// "skip the network entirely" for the one thing that mattered most:
/// the sidebar showing more than what was actually cached.
func testManualOfflineModeSkipsLiveForReadsToo() async throws {
let defaults = UserDefaults(suiteName: "CachingOutlineAPIClientTests.manualOfflineReads")!
defaults.removePersistentDomain(forName: "CachingOutlineAPIClientTests.manualOfflineReads")
let stub = StubOutlineAPIClient()
let cachedCollection = makeCollection(id: "col-1")
var liveCallCount = 0
stub.listCollectionsHandler = { _, _ in
liveCallCount += 1
return [cachedCollection]
}
let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache, defaults: defaults)
// Online first populates the cache normally.
let firstResult = try await sut.listCollections(offset: 0, limit: 25)
XCTAssertEqual(firstResult, [cachedCollection])
XCTAssertEqual(liveCallCount, 1)
// Flip manual offline mode on, still "connected" (stub would happily
// answer) the live call must not be attempted at all.
defaults.set(true, forKey: CachingOutlineAPIClient.offlineModeDefaultsKey)
let secondResult = try await sut.listCollections(offset: 0, limit: 25)
XCTAssertEqual(secondResult, [cachedCollection])
XCTAssertEqual(liveCallCount, 1, "listCollections should have served entirely from cache")
}
func testCacheStorageSummaryReflectsCachedItems() async throws {
let stub = StubOutlineAPIClient()
stub.documentInfoHandler = { _ in self.makeDocument() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
_ = try await sut.documentInfo(id: "doc-1")
let summary = await sut.cacheStorageSummary()
XCTAssertEqual(summary.itemCount, 1)
XCTAssertGreaterThan(summary.totalBytes, 0)
await sut.clearCache()
let clearedSummary = await sut.cacheStorageSummary()
XCTAssertEqual(clearedSummary.itemCount, 0)
}
// MARK: - Full sync
func testPerformFullSyncNeverRequestsMoreThan100CollectionsPerPage() async throws {
let stub = StubOutlineAPIClient()
var requestedLimits: [Int] = []
// 105 collections across two pages (100 + 5) regression test for a
// real bug: this used to ask for `limit: 250` in one shot, which
// Outline's server rejects outright ("Pagination limit is too large
// (max 100)"), turning the whole sync into a single silent failure.
stub.listCollectionsHandler = { offset, limit in
requestedLimits.append(limit)
let remaining = max(0, 105 - offset)
let count = min(limit, remaining)
return (0..<count).map { self.makeCollection(id: "col-\(offset + $0)") }
}
stub.listDocumentsHandler = { _, _, _, _ in [] }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let summary = await sut.performFullSync()
XCTAssertEqual(summary.collectionsCount, 105)
XCTAssertTrue(summary.errors.isEmpty)
XCTAssertTrue(requestedLimits.allSatisfy { $0 <= 100 }, "requested limits: \(requestedLimits)")
}
func testPerformFullSyncCachesEachDocumentIndividually() async throws {
let stub = StubOutlineAPIClient()
stub.listCollectionsHandler = { offset, _ in offset == 0 ? [self.makeCollection(id: "col-1")] : [] }
stub.listDocumentsHandler = { _, _, offset, _ in
offset == 0 ? [self.makeDocument(id: "doc-1"), self.makeDocument(id: "doc-2")] : []
}
let cache = try makeCache()
let sut = CachingOutlineAPIClient(live: stub, cache: cache)
let summary = await sut.performFullSync()
XCTAssertEqual(summary.documentsCount, 2)
// A plain documentInfo read (no live call available) should now hit
// the cache full sync populated, not just the list-shaped cache key.
stub.documentInfoHandler = { _ in throw StubTransportError() }
let cachedDoc = try await sut.documentInfo(id: "doc-2")
XCTAssertEqual(cachedDoc.id, "doc-2")
}
// MARK: - Offline document creation
func testCreateDocumentQueuesAndReturnsUsableDocumentWhenOffline() async throws {
let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let created = try await sut.createDocument(
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
)
XCTAssertTrue(created.id.hasPrefix("pending-"))
XCTAssertEqual(created.title, "New Doc")
XCTAssertEqual(created.text, "hello")
// It's immediately readable, same as any other cached document.
stub.documentInfoHandler = { _ in throw StubTransportError() }
let reopened = try await sut.documentInfo(id: created.id)
XCTAssertEqual(reopened.title, "New Doc")
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1)
XCTAssertEqual(pending.first?.kind, "createDocument")
}
func testEditingAnUnsyncedCreatedDocumentFoldsIntoTheCreateInsteadOfQueuingAnUpdate() async throws {
let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() }
stub.updateDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let created = try await sut.createDocument(
CreateDocumentRequest(title: "Untitled", text: "", collectionId: "col-1")
)
_ = try await sut.updateDocument(UpdateDocumentRequest(id: created.id, title: "Real Title", text: "Real body"))
let pending = await sut.pendingOperations()
XCTAssertEqual(pending.count, 1, "the edit should have folded into the pending create, not added a second operation")
XCTAssertEqual(pending.first?.kind, "createDocument")
stub.documentInfoHandler = { _ in throw StubTransportError() }
let reopened = try await sut.documentInfo(id: created.id)
XCTAssertEqual(reopened.title, "Real Title")
XCTAssertEqual(reopened.text, "Real body")
}
func testFlushingAPendingCreateReconcilesThePlaceholderIdToTheRealOne() async throws {
let stub = StubOutlineAPIClient()
stub.createDocumentHandler = { _ in throw StubTransportError() }
let sut = CachingOutlineAPIClient(live: stub, cache: try makeCache())
let created = try await sut.createDocument(
CreateDocumentRequest(title: "New Doc", text: "hello", collectionId: "col-1")
)
let realDocument = makeDocument(id: "real-server-id", title: "New Doc")
stub.createDocumentHandler = { _ in realDocument }
let summary = await sut.flushPendingOperations()
XCTAssertEqual(summary.succeeded, 1)
XCTAssertEqual(summary.failed, 0)
// The real document is now readable under its real id...
stub.documentInfoHandler = { _ in throw StubTransportError() }
let byRealId = try await sut.documentInfo(id: "real-server-id")
XCTAssertEqual(byRealId.id, "real-server-id")
// ...and the placeholder is gone rather than left as a dead orphan.
do {
_ = try await sut.documentInfo(id: created.id)
XCTFail("Expected the placeholder cache entry to have been removed")
} catch {
// expected nothing left under the old id
}
}
}
+2 -2
View File
@@ -408,7 +408,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 27.0; MACOSX_DEPLOYMENT_TARGET = 27.0;
MARKETING_VERSION = 0.0.2; MARKETING_VERSION = 0.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost; PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -453,7 +453,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 27.0; MACOSX_DEPLOYMENT_TARGET = 27.0;
MARKETING_VERSION = 0.0.2; MARKETING_VERSION = 0.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost; PRODUCT_BUNDLE_IDENTIFIER = com.psmattas.Outpost;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
+15 -5
View File
@@ -2,11 +2,15 @@
import AppKit import AppKit
import SwiftUI import SwiftUI
struct AboutView: View { /// Bare content (icon, name, version, links) with no window chrome reused
/// by both the standalone "About Outpost" window (`AboutView`, the standard
/// macOS app-menu affordance) and the Settings page's own About section, so
/// the two can't drift out of sync.
struct AboutInfoView: View {
private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")! private let repositoryURL = URL(string: "https://git.psmattas.com/psmattas/Outpost")!
private let releasesURL = URL(string: "https://git.psmattas.com/psmattas/Outpost/releases")! private let releasesURL = URL(string: "https://git.psmattas.com/psmattas/Outpost/releases")!
private var appName: String { var appName: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Outpost" Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String ?? "Outpost"
} }
@@ -15,7 +19,7 @@ struct AboutView: View {
/// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`. /// expected to stay a plain dotted-numeric string, not `0.0.1-ALPHA`.
private let releaseStage = "ALPHA" private let releaseStage = "ALPHA"
private var versionString: String { var versionString: String {
let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1" let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1"
let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1" let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)" let stageSuffix = releaseStage.isEmpty ? "" : "-\(releaseStage)"
@@ -66,8 +70,6 @@ struct AboutView: View {
.font(.caption2) .font(.caption2)
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
} }
.padding(32)
.frame(width: 320)
} }
// No Sparkle-style in-app updater yet this just opens the releases page // No Sparkle-style in-app updater yet this just opens the releases page
@@ -76,4 +78,12 @@ struct AboutView: View {
NSWorkspace.shared.open(releasesURL) NSWorkspace.shared.open(releasesURL)
} }
} }
struct AboutView: View {
var body: some View {
AboutInfoView()
.padding(32)
.frame(width: 320)
}
}
#endif #endif
+13 -2
View File
@@ -1,5 +1,6 @@
#if os(macOS) #if os(macOS)
import SwiftUI import SwiftUI
import OutlineKit
/// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label /// Uses a plain `Button` + `.popover` rather than `Menu`. A `Menu` whose label
/// contains the avatar image reliably broke its own sizing on click (even after /// contains the avatar image reliably broke its own sizing on click (even after
@@ -7,10 +8,11 @@ import SwiftUI
/// label rendering, which a `Button` doesn't go through. /// label rendering, which a `Button` doesn't go through.
struct AccountFooter: View { struct AccountFooter: View {
@Environment(SessionStore.self) private var session @Environment(SessionStore.self) private var session
@Environment(AppNavigation.self) private var navigation
@Environment(\.openURL) private var openURL @Environment(\.openURL) private var openURL
@Environment(\.openWindow) private var openWindow @Environment(\.openWindow) private var openWindow
@Environment(\.openSettings) private var openSettings
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system @AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@State private var isMenuPresented = false @State private var isMenuPresented = false
@State private var isShowingLogoutConfirmation = false @State private var isShowingLogoutConfirmation = false
@State private var isShowingProfile = false @State private var isShowingProfile = false
@@ -93,8 +95,17 @@ struct AccountFooter: View {
.padding(.horizontal, 6) .padding(.horizontal, 6)
.padding(.vertical, 4) .padding(.vertical, 4)
Toggle("Offline Mode", isOn: $isOfflineModeEnabled)
.padding(.horizontal, 6)
.padding(.vertical, 4)
menuItem("Profile…") { isShowingProfile = true } menuItem("Profile…") { isShowingProfile = true }
menuItem("Preferences…") { openSettings() } // Deferred a tick: setting this synchronously in the same call
// that dismisses this popover collides two AppKit window/layer
// transactions in the same runloop turn (visible in the console
// as "Invalid attempt to open a new transaction during CA
// commit") letting the popover's dismissal finish first avoids it.
menuItem("Settings…") { Task { @MainActor in navigation.isShowingSettings = true } }
Divider() Divider()
@@ -1,40 +0,0 @@
#if os(macOS)
import SwiftUI
struct PreferencesView: View {
@Environment(SessionStore.self) private var session
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
@State private var isShowingLogoutConfirmation = false
var body: some View {
Form {
Section("Appearance") {
Picker("Appearance", selection: $appearance) {
ForEach(AppAppearance.allCases) { option in
Text(option.label).tag(option)
}
}
.pickerStyle(.segmented)
.labelsHidden()
}
Section("Account") {
LabeledContent("Signed in as", value: session.userName ?? "")
if let email = session.userEmail {
LabeledContent("Email", value: email)
}
if let teamName = session.teamName {
LabeledContent("Workspace", value: teamName)
}
Button("Log Out…", role: .destructive) {
isShowingLogoutConfirmation = true
}
}
}
.formStyle(.grouped)
.frame(width: 380, height: 300)
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
}
}
#endif
@@ -0,0 +1,41 @@
#if os(macOS)
import SwiftUI
/// Swapped into the real sidebar's content slot (search field, collections
/// tree, account footer) while Settings is open same sidebar, different
/// content, rather than a separate mini sidebar nested inside a page. "Done"
/// clears `AppNavigation.isShowingSettings`, which puts the collections tree
/// back.
struct SettingsSidebarList: View {
@Binding var selection: SettingsSection?
let onDone: () -> Void
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Settings")
.font(.headline)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
Divider()
List(SettingsSection.allCases, selection: $selection) { section in
Label(section.title, systemImage: section.icon)
.tag(section)
}
.listStyle(.sidebar)
Divider()
Button("Done", action: onDone)
.keyboardShortcut(.cancelAction)
.buttonStyle(.borderedProminent)
.frame(maxWidth: .infinity)
.padding(12)
}
}
}
#endif
+420
View File
@@ -0,0 +1,420 @@
#if os(macOS)
import SwiftUI
import OutlineKit
/// Settings *detail* content for one section the section list itself now
/// lives in `ContentView_macOS`'s real sidebar (swapped in over the
/// collections tree while `AppNavigation.isShowingSettings` is set, not a
/// separate mini sidebar of its own), so this view only ever renders
/// whichever section is currently selected.
struct SettingsView: View {
let section: SettingsSection
@Environment(SessionStore.self) private var session
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
@AppStorage("outpost.advancedOptionsEnabled") private var isAdvancedOptionsEnabled = false
@State private var isShowingLogoutConfirmation = false
@State private var isShowingAdvancedWarning = false
@State private var storageSummary: CacheStorageSummary?
@State private var pendingOperations: [PendingOperationSummary] = []
@State private var isSyncing = false
@State private var isClearingCache = false
@State private var didClearCache = false
@State private var lastFullSyncSummary: FullSyncSummary?
@State private var lastFlushSummary: SyncFlushSummary?
/// Full Local Sync and cache-clearing both need a real connection to be
/// safe clearing while offline (or letting Full Local Sync think it
/// should be running) can leave the app with nothing local to show and
/// no way to refetch it. "Offline" here means either a real dropped
/// connection or the user's own manual toggle both leave the app with
/// no server to talk to.
private var isEffectivelyOnline: Bool {
session.networkMonitor.isOnline && !isOfflineModeEnabled
}
var body: some View {
// GeometryReader + `minHeight` (not `maxHeight`) is the actual fix for
// "center short content inside a ScrollView" a ScrollView proposes
// effectively unbounded height to its content, so `maxHeight: .infinity`
// alone just resolves to the content's own intrinsic size and does
// nothing; forcing a `minHeight` equal to the real viewport height is
// what gives `aboutDetail`'s own centered alignment somewhere to
// actually center within. Harmless for the other (already
// top/leading-aligned) sections short ones just get blank space
// below, same as before.
GeometryReader { geometry in
ScrollView {
sectionDetail
.padding(28)
.frame(maxWidth: .infinity, minHeight: geometry.size.height, alignment: .topLeading)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.background)
.task { await refreshSyncState() }
.logoutConfirmationDialog(isPresented: $isShowingLogoutConfirmation, session: session)
}
@ViewBuilder
private var sectionDetail: some View {
switch section {
case .appearance: appearanceDetail
case .account: accountDetail
case .offlineSync: offlineSyncDetail
case .advanced: advancedDetail
case .about: aboutDetail
}
}
private var sectionHeader: some View {
Text(section.title)
.font(.title.bold())
}
// MARK: - Appearance
private var appearanceDetail: some View {
VStack(alignment: .leading, spacing: 16) {
sectionHeader
Picker("Appearance", selection: $appearance) {
ForEach(AppAppearance.allCases) { option in
Text(option.label).tag(option)
}
}
.pickerStyle(.segmented)
.labelsHidden()
.frame(maxWidth: 320)
}
}
// MARK: - Account
private var accountDetail: some View {
VStack(alignment: .leading, spacing: 16) {
sectionHeader
VStack(alignment: .leading, spacing: 10) {
labeledRow("Signed in as", session.userName ?? "")
if let email = session.userEmail {
labeledRow("Email", email)
}
if let teamName = session.teamName {
labeledRow("Workspace", teamName)
}
}
.frame(maxWidth: 420)
Button("Log Out…", role: .destructive) {
isShowingLogoutConfirmation = true
}
}
}
// MARK: - Offline & Sync
private var offlineSyncDetail: some View {
VStack(alignment: .leading, spacing: 20) {
sectionHeader
VStack(alignment: .leading, spacing: 6) {
Toggle("Offline Mode", isOn: $isOfflineModeEnabled)
Text("Skip the network entirely and work from what's already been cached. Turn this off to reconnect.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 480, alignment: .leading)
VStack(alignment: .leading, spacing: 6) {
Toggle("Full Local Sync", isOn: $isFullLocalSyncEnabled)
.disabled(!isEffectivelyOnline)
Text("Keep a complete local copy of every collection and document, not just what's been opened — the whole workspace stays browsable offline. Runs automatically in the background once on; no need to trigger it by hand.")
.font(.caption)
.foregroundStyle(.secondary)
if !isEffectivelyOnline {
Text("Requires an internet connection to turn on or off.")
.font(.caption2)
.foregroundStyle(.orange)
}
}
.frame(maxWidth: 480, alignment: .leading)
.help(isEffectivelyOnline ? "" : "Full Local Sync needs a real connection — it can't safely turn on (or off) while offline.")
if isFullLocalSyncEnabled {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
if isSyncing {
ProgressView().controlSize(.small)
Text("Syncing…")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Button("Sync Now") { Task { await runFullSync() } }
.controlSize(.small)
.disabled(!isEffectivelyOnline)
if let lastFullSyncSummary {
Text(fullSyncSummaryText(lastFullSyncSummary))
.font(.caption)
.foregroundStyle(lastFullSyncSummary.errors.isEmpty ? Color.secondary : Color.red)
}
}
}
if let lastFullSyncSummary, !isSyncing {
ForEach(Array(lastFullSyncSummary.errors.enumerated()), id: \.offset) { _, message in
Text(message)
.font(.caption2)
.foregroundStyle(.red)
}
}
}
}
Divider()
.frame(maxWidth: 480)
pendingOperationsRow
.frame(maxWidth: 480, alignment: .leading)
}
}
private var pendingOperationsRow: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Pending Sync")
.font(.subheadline.weight(.medium))
Spacer()
if isSyncing {
ProgressView().controlSize(.small)
} else {
Button("Retry") { Task { await retrySync() } }
.buttonStyle(.plain)
.font(.caption)
.foregroundStyle(Color.accentColor)
.disabled(pendingOperations.isEmpty)
}
}
if pendingOperations.isEmpty {
Text("Everything's synced.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 6) {
ForEach(pendingOperations) { operation in
pendingOperationRow(operation)
}
}
}
}
}
private func pendingOperationRow(_ operation: PendingOperationSummary) -> some View {
HStack(alignment: .top, spacing: 6) {
Image(systemName: operation.lastError == nil ? "clock" : "exclamationmark.triangle.fill")
.foregroundStyle(operation.lastError == nil ? Color.secondary : Color.orange)
.font(.caption)
VStack(alignment: .leading, spacing: 2) {
Text(operationLabel(operation.kind))
.font(.caption)
if let lastError = operation.lastError {
Text(lastError)
.font(.caption2)
.foregroundStyle(.red)
}
}
}
}
private func operationLabel(_ kind: String) -> String {
switch kind {
case "updateDocument": return "Document edit"
case "updateCollection": return "Collection rename"
case "createPin": return "Pin"
case "deletePin": return "Unpin"
case "createSubscription": return "Subscribe"
case "deleteSubscription": return "Unsubscribe"
case "starDocument", "starCollection": return "Star"
case "deleteStar": return "Unstar"
default: return kind
}
}
// MARK: - Advanced
/// Everything here either does something destructive (clearing the
/// cache Full Local Sync just spent minutes building) or doesn't exist
/// yet gating all of it behind an off-by-default master toggle plus a
/// confirmation to turn that toggle on is the safeguard: nothing here
/// can be reached by accident, and nothing outside this section can
/// touch the cache at all, so there's no path to "messed up Full Local
/// Sync" that doesn't go through this screen on purpose.
private var advancedDetail: some View {
VStack(alignment: .leading, spacing: 20) {
sectionHeader
VStack(alignment: .leading, spacing: 6) {
Toggle("Enable Advanced Options", isOn: advancedOptionsBinding)
Text("Off by default on purpose. Turning this on unlocks things that can cause unintended behavior, including permanently losing your local cache.")
.font(.caption)
.foregroundStyle(.secondary)
}
.frame(maxWidth: 480, alignment: .leading)
Divider()
.frame(maxWidth: 480)
VStack(alignment: .leading, spacing: 12) {
comingSoonRow("Export All Data")
comingSoonRow("Developer Diagnostics")
comingSoonRow("Reset Local Database")
}
.frame(maxWidth: 480, alignment: .leading)
Divider()
.frame(maxWidth: 480)
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 10) {
Text("Clear All Cache")
Spacer()
if didClearCache {
Label("Cleared", systemImage: "checkmark")
.font(.caption)
.foregroundStyle(.secondary)
}
Button(role: .destructive) {
Task { await clearCache() }
} label: {
if isClearingCache {
ProgressView().controlSize(.small)
} else {
Text("Clear")
}
}
.buttonStyle(.borderedProminent)
.tint(.red)
}
if let storageSummary {
Text("\(storageSummary.itemCount) items — \(formattedBytes(storageSummary.totalBytes))")
.font(.caption)
.foregroundStyle(.secondary)
}
Text("Deletes every cached collection and document, including anything Full Local Sync built, and anything still waiting to sync. Doesn't touch the server. This is the only place that can — it works even while offline, which is exactly why it's behind this toggle.")
.font(.caption2)
.foregroundStyle(.secondary)
}
.disabled(!isAdvancedOptionsEnabled || isClearingCache)
.opacity(isAdvancedOptionsEnabled ? 1 : 0.4)
.frame(maxWidth: 480, alignment: .leading)
}
.confirmationDialog(
"Enable Advanced Options?",
isPresented: $isShowingAdvancedWarning,
titleVisibility: .visible
) {
Button("Enable", role: .destructive) { isAdvancedOptionsEnabled = true }
Button("Cancel", role: .cancel) {}
} message: {
Text("These settings can cause unintended behavior, including permanently losing your local cache. Only continue if you know what you're doing.")
}
}
/// Never writes `true` directly turning the toggle on only opens the
/// warning dialog; only that dialog's own "Enable" button actually sets
/// it. Turning off doesn't need confirmation.
private var advancedOptionsBinding: Binding<Bool> {
Binding(
get: { isAdvancedOptionsEnabled },
set: { newValue in
if newValue {
isShowingAdvancedWarning = true
} else {
isAdvancedOptionsEnabled = false
}
}
)
}
private func comingSoonRow(_ title: String) -> some View {
HStack {
Text(title)
Spacer()
Text("Coming Soon")
.font(.caption)
.foregroundStyle(.secondary)
}
.disabled(true)
.opacity(0.5)
}
// MARK: - About
private var aboutDetail: some View {
VStack(spacing: 16) {
sectionHeader
AboutInfoView()
.frame(maxWidth: 420)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
// MARK: - Helpers
private func labeledRow(_ label: String, _ value: String) -> some View {
HStack {
Text(label)
.foregroundStyle(.secondary)
Spacer()
Text(value)
}
.font(.callout)
}
private func formattedBytes(_ bytes: Int) -> String {
ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)
}
private func fullSyncSummaryText(_ summary: FullSyncSummary) -> String {
if summary.errors.isEmpty {
return "Synced \(summary.documentsCount) documents across \(summary.collectionsCount) collections."
}
return "Synced with \(summary.errors.count) error\(summary.errors.count == 1 ? "" : "s")."
}
private func refreshSyncState() async {
guard let cachingClient = session.cachingClient else { return }
storageSummary = await cachingClient.cacheStorageSummary()
pendingOperations = await cachingClient.pendingOperations()
}
private func runFullSync() async {
guard let cachingClient = session.cachingClient, !isSyncing else { return }
isSyncing = true
defer { isSyncing = false }
lastFullSyncSummary = await cachingClient.performFullSync()
await refreshSyncState()
}
private func retrySync() async {
guard let cachingClient = session.cachingClient, !isSyncing else { return }
isSyncing = true
defer { isSyncing = false }
lastFlushSummary = await cachingClient.flushPendingOperations()
await refreshSyncState()
}
private func clearCache() async {
guard let cachingClient = session.cachingClient else { return }
isClearingCache = true
defer { isClearingCache = false }
await cachingClient.clearCache()
await refreshSyncState()
didClearCache = true
Task {
try? await Task.sleep(for: .seconds(2))
didClearCache = false
}
}
}
#endif
@@ -10,7 +10,7 @@ struct CollectionRowView: View {
} icon: { } icon: {
if let emoji = collection.emojiIcon { if let emoji = collection.emojiIcon {
Text(emoji) Text(emoji)
} else if let symbolName = collection.icon.flatMap(OutlineIconMapping.sfSymbolName) { } else if let symbolName = collection.icon.flatMap({ OutlineIconMapping.sfSymbolName(for: $0) }) {
Image(systemName: symbolName) Image(systemName: symbolName)
.foregroundStyle(tintColor) .foregroundStyle(tintColor)
} else { } else {
@@ -4,6 +4,8 @@ import OutlineKit
struct ContentView_macOS: View { struct ContentView_macOS: View {
@Environment(SessionStore.self) private var session @Environment(SessionStore.self) private var session
@Environment(AppNavigation.self) private var navigation
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
/// The landing state no collection selected yet is what Home actually /// The landing state no collection selected yet is what Home actually
/// means, so this starts `true` rather than auto-selecting the first /// means, so this starts `true` rather than auto-selecting the first
/// collection the way this used to work. /// collection the way this used to work.
@@ -26,11 +28,73 @@ struct ContentView_macOS: View {
globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines) globalSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
} }
/// `@Environment(AppNavigation.self)` doesn't hand out `$`-bindings on its
/// own (that's `@Bindable`'s job, and introducing one in `body` would
/// mean restructuring it away from a single implicit-return expression)
/// a manually-built `Binding` over the same reference is simpler here.
private var selectedSettingsSectionBinding: Binding<SettingsSection?> {
Binding(
get: { navigation.selectedSettingsSection },
set: { navigation.selectedSettingsSection = $0 }
)
}
var body: some View { var body: some View {
// A totally separate top-level branch, not content swapped inside
// one persistent `NavigationSplitView` that was tried first (an
// `if/else` inside a single split view, then an explicit `.id()` on
// just the detail pane) and neither reliably replaced the detail
// pane's content on macOS: `NavigationSplitView` bridges to
// `NSSplitViewController`, and swapping a `NavigationStack` with
// real push history for a plain view inside one persisting instance
// doesn't propagate the way plain SwiftUI identity rules would
// suggest. A different `if` branch is a genuinely different view
// hierarchy, so there's no existing split view instance for AppKit
// to get confused about reusing.
if navigation.isShowingSettings {
settingsContent
} else {
mainContent
}
}
private var settingsContent: some View {
NavigationSplitView {
SettingsSidebarList(
selection: selectedSettingsSectionBinding,
onDone: { navigation.isShowingSettings = false }
)
.navigationSplitViewColumnWidth(min: 220, ideal: 260)
} detail: {
SettingsView(section: navigation.selectedSettingsSection ?? .appearance)
}
.navigationTitle("")
.toolbar {
ToolbarItem(placement: .navigation) {
HStack(spacing: 6) {
Image(systemName: "gearshape.fill")
Text("Settings")
}
.font(.headline)
.padding(.horizontal, 10)
.padding(.vertical, 4)
.background(.fill.tertiary, in: Capsule())
}
}
}
private var mainContent: some View {
NavigationSplitView { NavigationSplitView {
VStack(spacing: 0) { VStack(spacing: 0) {
SidebarSearchField(text: $globalSearchQuery) SidebarSearchField(text: $globalSearchQuery)
Divider() Divider()
if isOfflineModeEnabled {
OfflineBanner(isManual: true)
Divider()
} else if !session.networkMonitor.isOnline {
OfflineConnectionPromptBanner(onEnableOfflineMode: { isOfflineModeEnabled = true })
Divider()
}
sidebar sidebar
AccountFooter() AccountFooter()
} }
@@ -92,6 +156,7 @@ struct ContentView_macOS: View {
isContextualSearchExpanded = false isContextualSearchExpanded = false
selectedCollection = nil selectedCollection = nil
isShowingHome = true isShowingHome = true
navigation.isShowingSettings = false
replaceDocumentPath(with: []) replaceDocumentPath(with: [])
} }
@@ -12,6 +12,7 @@ import OutlineKit
struct DocumentReaderView: View { struct DocumentReaderView: View {
@Environment(SessionStore.self) private var session @Environment(SessionStore.self) private var session
@Environment(StarStore.self) private var starStore @Environment(StarStore.self) private var starStore
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
@State private var viewModel: DocumentReaderViewModel @State private var viewModel: DocumentReaderViewModel
let apiClient: OutlineAPIClient let apiClient: OutlineAPIClient
@@ -56,6 +57,15 @@ struct DocumentReaderView: View {
self.onDocumentCreated = onDocumentCreated self.onDocumentCreated = onDocumentCreated
} }
/// New Document, editing, pin/star/subscribe, and Full Width all queue
/// and sync later (see `CachingOutlineAPIClient`) everything else here
/// (sharing, permissions, move/archive/delete/duplicate/templatize,
/// history, insights, export) hits the server directly with no offline
/// path, so it's disabled rather than left to fail confusingly on tap.
private var isEffectivelyOnline: Bool {
session.networkMonitor.isOnline && !isOfflineModeEnabled
}
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
@@ -82,6 +92,7 @@ struct DocumentReaderView: View {
NativeTextViewWrapper( NativeTextViewWrapper(
text: $viewModel.text, text: $viewModel.text,
configuration: .init(heightBehavior: .fitsContent), configuration: .init(heightBehavior: .fitsContent),
documentId: viewModel.documentId,
isEditable: viewModel.isEditing isEditable: viewModel.isEditing
) )
@@ -110,7 +121,8 @@ struct DocumentReaderView: View {
} label: { } label: {
Image(systemName: "square.and.arrow.up") Image(systemName: "square.and.arrow.up")
} }
.help("Share") .help(isEffectivelyOnline ? "Share" : "Sharing needs an internet connection")
.disabled(!isEffectivelyOnline)
.popover(isPresented: $isShowingShareSheet, arrowEdge: .bottom) { .popover(isPresented: $isShowingShareSheet, arrowEdge: .bottom) {
DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId) DocumentShareSheet(apiClient: apiClient, documentId: viewModel.documentId)
} }
@@ -235,7 +247,8 @@ struct DocumentReaderView: View {
viewModel.isPinned, viewModel.isPinned,
viewModel.isInsightsEnabled ?? false, viewModel.isInsightsEnabled ?? false,
viewModel.isFullWidth, viewModel.isFullWidth,
viewModel.isEditing viewModel.isEditing,
isEffectivelyOnline
].map(String.init).joined(separator: "-") ].map(String.init).joined(separator: "-")
} }
@@ -305,27 +318,33 @@ struct DocumentReaderView: View {
Button("Permissions…") { Button("Permissions…") {
isShowingShareSheet = true isShowingShareSheet = true
} }
.disabled(!isEffectivelyOnline)
Divider() Divider()
Button("Templatize") { Button("Templatize") {
Task { await templatize() } Task { await templatize() }
} }
.disabled(!isEffectivelyOnline)
Button("Duplicate") { Button("Duplicate") {
Task { await duplicate() } Task { await duplicate() }
} }
.disabled(!isEffectivelyOnline)
Button("Unpublish") { Button("Unpublish") {
isShowingUnpublishConfirmation = true isShowingUnpublishConfirmation = true
} }
.disabled(!isEffectivelyOnline)
Button("Archive…") { Button("Archive…") {
isShowingArchiveConfirmation = true isShowingArchiveConfirmation = true
} }
.disabled(!isEffectivelyOnline)
Divider() Divider()
Button("Move") { Button("Move") {
isShowingMoveSheet = true isShowingMoveSheet = true
} }
.disabled(!isEffectivelyOnline)
// Multipart file upload is its own subsystem deferred rather than // Multipart file upload is its own subsystem deferred rather than
// half-built here. // half-built here.
Button("Import Document…") {} Button("Import Document…") {}
@@ -342,9 +361,13 @@ struct DocumentReaderView: View {
Button("History") { Button("History") {
isShowingHistorySheet = true isShowingHistorySheet = true
} }
.disabled(!isEffectivelyOnline)
Button("Insights") { Button("Insights") {
isShowingInsightsSheet = true isShowingInsightsSheet = true
} }
.disabled(!isEffectivelyOnline)
// Present/Search in Document both read `documents.info`, which is
// read-through cached they work offline on whatever's cached.
Button("Present") { Button("Present") {
isShowingPresentSheet = true isShowingPresentSheet = true
} }
@@ -354,6 +377,7 @@ struct DocumentReaderView: View {
Button("Download") { Button("Download") {
Task { await download() } Task { await download() }
} }
.disabled(!isEffectivelyOnline)
Button("Copy") { Button("Copy") {
Task { await copyMarkdown() } Task { await copyMarkdown() }
} }
@@ -370,6 +394,7 @@ struct DocumentReaderView: View {
get: { viewModel.isInsightsEnabled ?? false }, get: { viewModel.isInsightsEnabled ?? false },
set: { _ in Task { await toggleInsights() } } set: { _ in Task { await toggleInsights() } }
)) ))
.disabled(!isEffectivelyOnline)
// Confirmed against a live server: there's no per-document embeds // Confirmed against a live server: there's no per-document embeds
// field. Only a workspace-level setting exists, and that's not // field. Only a workspace-level setting exists, and that's not
// reachable via the API either (no `team.update` endpoint in the // reachable via the API either (no `team.update` endpoint in the
@@ -386,6 +411,7 @@ struct DocumentReaderView: View {
Button("Delete…", role: .destructive) { Button("Delete…", role: .destructive) {
isShowingDeleteConfirmation = true isShowingDeleteConfirmation = true
} }
.disabled(!isEffectivelyOnline)
} }
private func star() async { private func star() async {
@@ -0,0 +1,27 @@
#if os(macOS)
import SwiftUI
/// Shown at the top of the sidebar whenever the app is offline either for
/// real (`NetworkMonitor` reports no connection) or because the user turned
/// on the manual "Offline Mode" toggle. Cached collections/documents keep
/// browsing working either way, but the user should know they might be
/// looking at stale data.
struct OfflineBanner: View {
/// Whether this is the user's own "Offline Mode" toggle rather than a
/// real dropped connection same banner slot, different explanation.
var isManual: Bool = false
var body: some View {
HStack(spacing: 8) {
Image(systemName: "wifi.slash")
.foregroundStyle(.orange)
Text(isManual ? "Offline Mode — showing cached content" : "Offline — showing cached content")
.font(.callout)
Spacer(minLength: 0)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.orange.opacity(0.12))
}
}
#endif
@@ -0,0 +1,30 @@
#if os(macOS)
import SwiftUI
/// Shown in the sidebar when the network is actually down and the user
/// hasn't turned on Offline Mode themselves yet. Deliberately doesn't change
/// any fetch behavior on its own reads still try live and fall back to
/// cache the normal way (see `CachingOutlineAPIClient`) until the user
/// actually taps the button here, same as `RemoteChangesBanner` never
/// auto-refreshes on their behalf either.
struct OfflineConnectionPromptBanner: View {
let onEnableOfflineMode: () -> Void
var body: some View {
HStack(spacing: 8) {
Image(systemName: "wifi.slash")
.foregroundStyle(.orange)
Text("No connection")
.font(.callout)
Spacer(minLength: 8)
Button("Turn On Offline Mode", action: onEnableOfflineMode)
.buttonStyle(.borderedProminent)
.tint(.orange)
.controlSize(.small)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.orange.opacity(0.12))
}
}
#endif
+15 -7
View File
@@ -57,25 +57,33 @@ final class HomeViewModel {
/// Fetches fresh pinned docs and the current tab's documents to compare /// Fetches fresh pinned docs and the current tab's documents to compare
/// against what's displayed, without replacing either. Bails silently /// against what's displayed, without replacing either. Bails silently
/// on a fetch failure rather than treating it as "changed" a /// on a fetch failure rather than treating it as "changed" a
/// transient network hiccup shouldn't pop the refresh banner. /// transient network hiccup (or, offline, `listPins` failing outright
/// it isn't one of the cached endpoints) shouldn't pop the refresh
/// banner. This needs the *throwing* pinned-fetch specifically: the
/// plain `fetchPinned()` used elsewhere collapses any failure to `[]`,
/// which used to read here as "pins changed" against whatever was
/// already displayed and falsely popped the banner on every offline
/// poll.
func checkForRemoteChanges(tab: HomeTab) async { func checkForRemoteChanges(tab: HomeTab) async {
async let freshPinned = fetchPinned() async let freshPinnedTask = fetchPinnedThrowing()
guard let freshTab = try? await fetch(tab: tab) else { return } guard let freshTab = try? await fetch(tab: tab) else { return }
let pinned = await freshPinned guard let pinned = try? await freshPinnedTask else { return }
if Self.fingerprint(pinned) != Self.fingerprint(pinnedDocuments) if Self.fingerprint(pinned) != Self.fingerprint(pinnedDocuments)
|| Self.fingerprint(freshTab) != Self.fingerprint(documents(for: tab)) { || Self.fingerprint(freshTab) != Self.fingerprint(documents(for: tab)) {
hasRemoteChanges = true hasRemoteChanges = true
} }
} }
private func fetchPinned() async -> [OutlineDocument] {
(try? await fetchPinnedThrowing()) ?? []
}
/// `pins.list` only returns pin records, not the documents themselves /// `pins.list` only returns pin records, not the documents themselves
/// fetches each pinned document individually. Pins are a small curated /// fetches each pinned document individually. Pins are a small curated
/// 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 fetchPinned() async -> [OutlineDocument] { private func fetchPinnedThrowing() async throws -> [OutlineDocument] {
guard let pins = try? await apiClient.listPins(ListPinsRequest(collectionId: nil)) else { let pins = try await apiClient.listPins(ListPinsRequest(collectionId: nil))
return []
}
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) {
+12 -5
View File
@@ -14,6 +14,7 @@ import AppKit
@main @main
struct OutpostApp: App { struct OutpostApp: App {
@State private var session = SessionStore() @State private var session = SessionStore()
@State private var navigation = AppNavigation()
@AppStorage("outpost.appearance") private var appearance: AppAppearance = .system @AppStorage("outpost.appearance") private var appearance: AppAppearance = .system
#if os(macOS) #if os(macOS)
@@ -25,6 +26,7 @@ struct OutpostApp: App {
WindowGroup { WindowGroup {
RootView() RootView()
.environment(session) .environment(session)
.environment(navigation)
#if os(iOS) #if os(iOS)
.preferredColorScheme(appearance.colorScheme) .preferredColorScheme(appearance.colorScheme)
#endif #endif
@@ -41,6 +43,16 @@ struct OutpostApp: App {
openWindow(id: "about") openWindow(id: "about")
} }
} }
// No `Settings {}` scene anymore Settings renders inside the
// root window (see `AppNavigation`), not a separate popup, so
// , has to be wired up by hand instead of coming for free.
CommandGroup(replacing: .appSettings) {
Button("Settings…") {
navigation.isShowingSettings = true
}
.keyboardShortcut(",")
.disabled(!session.isSignedIn)
}
CommandGroup(after: .appSettings) { CommandGroup(after: .appSettings) {
Divider() Divider()
Button("Log Out…") { Button("Log Out…") {
@@ -63,11 +75,6 @@ struct OutpostApp: App {
.disablesFullScreen() .disablesFullScreen()
} }
.windowResizability(.contentSize) .windowResizability(.contentSize)
Settings {
PreferencesView()
.environment(session)
}
#endif #endif
} }
+40
View File
@@ -0,0 +1,40 @@
import Observation
enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
case appearance, account, offlineSync, advanced, about
var id: String { rawValue }
var title: String {
switch self {
case .appearance: return "Appearance"
case .account: return "Account"
case .offlineSync: return "Offline & Sync"
case .advanced: return "Advanced"
case .about: return "About"
}
}
var icon: String {
switch self {
case .appearance: return "paintbrush"
case .account: return "person.crop.circle"
case .offlineSync: return "arrow.triangle.2.circlepath"
case .advanced: return "wrench.and.screwdriver"
case .about: return "info.circle"
}
}
}
/// Cross-cutting UI state that doesn't belong to any one screen. Lives at
/// `OutpostApp` (so both `RootView`'s content and its `,` command can reach
/// it) and is read wherever something needs to open Settings (the profile
/// menu) or render it as a swap of the *existing* sidebar/detail panes in
/// `ContentView_macOS`, not a separate popup window or an overlay that hides
/// the sidebar.
@Observable
@MainActor
final class AppNavigation {
var isShowingSettings = false
var selectedSettingsSection: SettingsSection? = .appearance
}
+55
View File
@@ -5,6 +5,19 @@ struct RootView: View {
@Environment(SessionStore.self) private var session @Environment(SessionStore.self) private var session
@State private var welcomeName: String? @State private var welcomeName: String?
@State private var starStore = StarStore() @State private var starStore = StarStore()
@AppStorage("outpost.fullLocalSyncEnabled") private var isFullLocalSyncEnabled = false
@AppStorage(CachingOutlineAPIClient.offlineModeDefaultsKey) private var isOfflineModeEnabled = false
/// Real connectivity is only half of "can talk to the server" the
/// manual Offline Mode toggle is the other half. Syncing needs to
/// resume on either one clearing, not just a real reconnect: turning
/// the toggle off while the network had been up the whole time never
/// changes `networkMonitor.isOnline`, so keying the flush task off that
/// alone left pending operations stuck until the next real network
/// blip.
private var isEffectivelyOnline: Bool {
session.networkMonitor.isOnline && !isOfflineModeEnabled
}
var body: some View { var body: some View {
ZStack { ZStack {
@@ -32,6 +45,48 @@ struct RootView: View {
starStore.reset() starStore.reset()
} }
} }
// Replay whatever queued up while offline the moment either signal
// clears a real reconnect, or the user turning Offline Mode back
// off no need to wait for the user to open Settings and hit Retry.
// Also catches Full Local Sync back up immediately, rather than
// leaving it to wait out the rest of the periodic loop below.
//
// The flush itself gets a retry loop with a cooloff, not just one
// attempt: a single transient failure (one bad operation, a blip
// mid-flush) used to leave everything else stuck until the user
// manually hit Retry or connectivity changed again. Keeps retrying
// every 5 minutes for as long as *anything* is still pending and
// this signal stays on stops on its own once the queue is empty,
// and `.task(id:)` cancels/restarts it automatically if
// isEffectivelyOnline flips again in the meantime.
.task(id: isEffectivelyOnline) {
guard isEffectivelyOnline, let cachingClient = session.cachingClient else { return }
if isFullLocalSyncEnabled {
_ = await cachingClient.performFullSync()
}
while !Task.isCancelled {
_ = await cachingClient.flushPendingOperations()
let remaining = await cachingClient.pendingOperations()
guard !remaining.isEmpty else { break }
try? await Task.sleep(for: .seconds(300))
}
}
// Fully automatic this is the only place Full Local Sync actually
// runs from (Settings' "Sync Now" is just an on-demand nudge at the
// same call). Syncs immediately whenever this task (re)starts, which
// covers both "just switched on" and "was already on at launch"
// `.task(id:)` restarts on either, an `@AppStorage`-backed toggle
// changing anywhere updates every view reading that key then every
// 20 minutes after, for as long as it stays enabled.
.task(id: isFullLocalSyncEnabled) {
guard isFullLocalSyncEnabled else { return }
while !Task.isCancelled {
if isEffectivelyOnline, let cachingClient = session.cachingClient {
_ = await cachingClient.performFullSync()
}
try? await Task.sleep(for: .seconds(1200))
}
}
} }
private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) { private func startWelcomeTransition(_ result: AuthViewModel.AuthResult) {
+27 -7
View File
@@ -17,6 +17,16 @@ final class SessionStore {
var teamName: String? var teamName: String?
var teamAvatarURL: URL? var teamAvatarURL: URL?
private(set) var apiClient: OutlineAPIClient? private(set) var apiClient: OutlineAPIClient?
/// Same object as `apiClient` when the offline cache is available kept
/// as a separately-typed reference so Settings can reach cache/sync-queue
/// specific methods (`flushPendingOperations`, `performFullSync`, etc.)
/// without downcasting the protocol-typed `apiClient` everywhere.
private(set) var cachingClient: CachingOutlineAPIClient?
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? { var serverURL: URL? {
defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:)) defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
@@ -26,23 +36,32 @@ final class SessionStore {
self.tokenStore = tokenStore self.tokenStore = tokenStore
self.defaults = defaults self.defaults = defaults
self.isSignedIn = (try? tokenStore.token()) != nil self.isSignedIn = (try? tokenStore.token()) != nil
self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
if isSignedIn, let serverURL { if isSignedIn, let serverURL {
apiClient = LiveOutlineAPIClient( (apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore)
configuration: OutlineConfiguration(baseURL: serverURL),
tokenStore: tokenStore
)
} }
} }
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 = LiveOutlineAPIClient( (apiClient, cachingClient) = 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, CachingOutlineAPIClient?) {
let live = LiveOutlineAPIClient(
configuration: OutlineConfiguration(baseURL: serverURL), configuration: OutlineConfiguration(baseURL: serverURL),
tokenStore: tokenStore tokenStore: tokenStore
) )
apply(user: user, team: team, serverURL: serverURL) guard let cache else { return (live, nil) }
isSignedIn = true let caching = CachingOutlineAPIClient(live: live, cache: cache)
return (caching, caching)
} }
func signOut() { func signOut() {
@@ -55,6 +74,7 @@ final class SessionStore {
teamName = nil teamName = nil
teamAvatarURL = nil teamAvatarURL = nil
apiClient = nil apiClient = nil
cachingClient = nil
} }
/// Re-fetches user/workspace name/logo on relaunch, when the token survived but this /// Re-fetches user/workspace name/logo on relaunch, when the token survived but this
+36
View File
@@ -0,0 +1,36 @@
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() {
// Weak on the outer closure (it's held by `monitor` for the object's
// whole lifetime, so a strong capture here would be a retain cycle),
// then unwrapped once into a plain strong local the inner `Task`
// closure capturing that local `self` is what a nested closure needs
// to be consistent about capture semantics with its enclosing one.
monitor.pathUpdateHandler = { [weak self] path in
guard let self else { return }
let online = path.status == .satisfied
Task { @MainActor in
self.isOnline = online
}
}
monitor.start(queue: queue)
}
deinit {
monitor.cancel()
}
}