Extends CachingOutlineAPIClient with a scoped offline write queue:
updateDocument, updateCollection, pin/unpin, star/unstar, and
subscribe/unsubscribe now apply optimistically and queue via a new
PendingOperation (SwiftData) when they fail (or when a new manual
"Offline Mode" toggle forces it), then replay on reconnect via
flushPendingOperations(). Same-target edits coalesce into one queued
operation; a pin/unpin pair that never syncs cancels out instead of
queuing a delete the server never saw. Actions that would invent new
tree structure (create/move/archive/delete/duplicate) stay live-only —
reconciling a locally-invented id against the server's real one is a
separate, harder problem this pass doesn't take on. Sharing,
permissions, search, and export also stay live-only.
Added a "Full Local Sync" toggle that eagerly walks and caches the
whole workspace instead of only what's been opened, running
immediately on enable and every 20 minutes after while online.
Settings moved from a popup (Settings {} scene / PreferencesView) to
a full-page view rendered inside the root window (AppNavigation),
including the ⌘, shortcut. Folds in offline/sync management (storage
size, clear cache, pending-sync list with per-item retry) and the
About window's content (version, check for updates) so it's all in
one place.
8 new OutlineKit tests covering coalescing, cancel-out, flush
success/failure, and manual offline mode — 51/51 passing.
99 lines
3.9 KiB
Swift
99 lines
3.9 KiB
Swift
import Foundation
|
|
import Observation
|
|
import OutlineKit
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class SessionStore {
|
|
private static let serverURLDefaultsKey = "outline.serverURL"
|
|
|
|
private let tokenStore: TokenStoring
|
|
private let defaults: UserDefaults
|
|
|
|
var isSignedIn: Bool
|
|
var userName: String?
|
|
var userEmail: String?
|
|
var userAvatarURL: URL?
|
|
var teamName: String?
|
|
var teamAvatarURL: URL?
|
|
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? {
|
|
defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
|
|
}
|
|
|
|
init(tokenStore: TokenStoring = KeychainTokenStore(), defaults: UserDefaults = .standard) {
|
|
self.tokenStore = tokenStore
|
|
self.defaults = defaults
|
|
self.isSignedIn = (try? tokenStore.token()) != nil
|
|
self.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
|
|
|
|
if isSignedIn, let serverURL {
|
|
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore)
|
|
}
|
|
}
|
|
|
|
func signIn(serverURL: URL, user: OutlineUser, team: OutlineTeam) {
|
|
defaults.set(serverURL.absoluteString, forKey: Self.serverURLDefaultsKey)
|
|
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: serverURL, tokenStore: tokenStore, cache: cacheStore)
|
|
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),
|
|
tokenStore: tokenStore
|
|
)
|
|
guard let cache else { return (live, nil) }
|
|
let caching = CachingOutlineAPIClient(live: live, cache: cache)
|
|
return (caching, caching)
|
|
}
|
|
|
|
func signOut() {
|
|
try? tokenStore.clear()
|
|
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
|
|
isSignedIn = false
|
|
userName = nil
|
|
userEmail = nil
|
|
userAvatarURL = nil
|
|
teamName = nil
|
|
teamAvatarURL = nil
|
|
apiClient = nil
|
|
cachingClient = nil
|
|
}
|
|
|
|
/// Re-fetches user/workspace name/logo on relaunch, when the token survived but this
|
|
/// in-memory state didn't.
|
|
func refreshTeamInfoIfNeeded() async {
|
|
guard isSignedIn, teamName == nil, let apiClient, let serverURL else { return }
|
|
guard let auth = try? await apiClient.authInfo() else { return }
|
|
apply(user: auth.user, team: auth.team, serverURL: serverURL)
|
|
}
|
|
|
|
private func apply(user: OutlineUser, team: OutlineTeam, serverURL: URL) {
|
|
userName = user.name
|
|
userEmail = user.email
|
|
// Outline can return either an absolute URL or a server-relative path
|
|
// (e.g. `/api/files.get?key=...`) for avatarUrl — resolve against the
|
|
// configured server so relative paths don't fail as "unsupported URL".
|
|
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
|
teamName = team.name
|
|
teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
|
}
|
|
}
|