SessionStore.init treated a Keychain token alone as "signed in," without checking the paired serverURL in UserDefaults also existed. UserDefaults is scoped to the app's sandboxed container (keyed by bundle ID), while Keychain items can survive a reinstall or bundle ID change independently of it — hit this live after renaming the bundle ID: Keychain still had the old token, the new container had no serverURL, so isSignedIn came back true with apiClient nil. App landed on Home with nothing able to load instead of the login screen. Now requires both to be present to consider the session valid, and clears whichever half survived otherwise so a fresh sign-in rewrites both consistently.
140 lines
5.8 KiB
Swift
140 lines
5.8 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
|
|
private(set) var userId: String?
|
|
var userName: String?
|
|
var userEmail: String?
|
|
var userAvatarURL: URL?
|
|
var userLanguage: String?
|
|
var userPreferences: OutlineUserPreferences?
|
|
var userNotificationSettings: [String: Bool]?
|
|
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.cacheStore = (try? OfflineCacheStore.makeContainer()).map(OfflineCacheStore.init(modelContainer:))
|
|
|
|
let hasToken = (try? tokenStore.token()) != nil
|
|
let storedServerURL = defaults.string(forKey: Self.serverURLDefaultsKey).flatMap(URL.init(string:))
|
|
|
|
if hasToken, let storedServerURL {
|
|
isSignedIn = true
|
|
(apiClient, cachingClient) = Self.makeAPIClient(serverURL: storedServerURL, tokenStore: tokenStore, cache: cacheStore)
|
|
} else {
|
|
// Keychain and the sandboxed UserDefaults container don't
|
|
// always survive together — a Keychain item written by an
|
|
// older-signed build can outlive a reinstall that wipes the
|
|
// container (or vice versa), leaving a token with no server or
|
|
// a server with no token. Clear whichever half survived rather
|
|
// than showing a broken "signed in" UI with no working
|
|
// apiClient — a fresh sign-in rewrites both consistently.
|
|
if hasToken {
|
|
try? tokenStore.clear()
|
|
}
|
|
defaults.removeObject(forKey: Self.serverURLDefaultsKey)
|
|
isSignedIn = false
|
|
}
|
|
}
|
|
|
|
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
|
|
userId = nil
|
|
userName = nil
|
|
userEmail = nil
|
|
userAvatarURL = nil
|
|
userLanguage = nil
|
|
userPreferences = nil
|
|
userNotificationSettings = 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)
|
|
}
|
|
|
|
/// Settings calls this after a successful name/avatar change so the
|
|
/// sidebar's account footer and everywhere else reading these reflect
|
|
/// it immediately, without waiting for the next `auth.info` refresh.
|
|
func applyUpdatedProfile(_ user: OutlineUser) {
|
|
guard let serverURL else { return }
|
|
userId = user.id
|
|
userName = user.name
|
|
userAvatarURL = user.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
|
userLanguage = user.language
|
|
userPreferences = user.preferences
|
|
userNotificationSettings = user.notificationSettings
|
|
}
|
|
|
|
private func apply(user: OutlineUser, team: OutlineTeam, serverURL: URL) {
|
|
userId = user.id
|
|
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 }
|
|
userLanguage = user.language
|
|
userPreferences = user.preferences
|
|
userNotificationSettings = user.notificationSettings
|
|
teamName = team.name
|
|
teamAvatarURL = team.avatarUrl.flatMap { URL(string: $0, relativeTo: serverURL)?.absoluteURL }
|
|
}
|
|
}
|