import SwiftUI @main struct CalendarrApp: App { @State private var appState = AppState() var body: some Scene { WindowGroup { RootView() .environment(appState) } } } @Observable class AppState { var serverURL: String = "" var authToken: String = "" var username: String = "" var isAdmin: Bool = false var isConfigured: Bool { !serverURL.isEmpty } var isLoggedIn: Bool { !authToken.isEmpty } init() { serverURL = UserDefaults.standard.string(forKey: "serverURL") ?? "" authToken = Self.loadToken() username = UserDefaults.standard.string(forKey: "username") ?? "" isAdmin = UserDefaults.standard.bool(forKey: "isAdmin") } /// Find the stored token, migrating it forward from wherever an older build /// left it. Runs on every launch but does real work only once. /// /// Step 2 is the one that keeps existing users signed in. Before the /// `keychain-access-groups` entitlement existed, items landed in the app's /// own default group. Adding the entitlement makes the *first* array entry /// the new default — and that entry is deliberately the app's own group, so /// an unqualified query still resolves those items and we can copy them /// across instead of stranding them. private static func loadToken() -> String { let key = "authToken" // 1. Already in the shared group — the steady state. if let token = try? KeychainStore.get(key), !token.isEmpty { return token } // 2. In the app's own default group, written before the entitlement. if let token = try? KeychainStore.get(key, accessGroup: nil), !token.isEmpty { try? KeychainStore.set(token, for: key) try? KeychainStore.set(nil, for: key, accessGroup: nil) return token } // 3. In UserDefaults, written before secrets moved to the Keychain. if let legacy = UserDefaults.standard.string(forKey: key), !legacy.isEmpty { try? KeychainStore.set(legacy, for: key) UserDefaults.standard.removeObject(forKey: key) return legacy } return "" } func saveServer(url: String) { serverURL = url.trimmingCharacters(in: .whitespacesAndNewlines) if serverURL.hasSuffix("/") { serverURL = String(serverURL.dropLast()) } UserDefaults.standard.set(serverURL, forKey: "serverURL") } func saveLogin(token: String, user: String, admin: Bool) { authToken = token username = user isAdmin = admin try? KeychainStore.set(token, for: "authToken") // secret → Keychain, not UserDefaults UserDefaults.standard.set(user, forKey: "username") UserDefaults.standard.set(admin, forKey: "isAdmin") } func logout() { authToken = "" username = "" isAdmin = false try? KeychainStore.set(nil, for: "authToken") try? KeychainStore.set(nil, for: "authToken", accessGroup: nil) // pre-entitlement copy UserDefaults.standard.removeObject(forKey: "authToken") // pre-Keychain copy UserDefaults.standard.removeObject(forKey: "username") UserDefaults.standard.removeObject(forKey: "isAdmin") // The shared container outlives the session, so it has to be cleared // explicitly — otherwise widgets keep showing the signed-out user's data. WidgetStore.clear() } func resetServer() { logout() serverURL = "" UserDefaults.standard.removeObject(forKey: "serverURL") } }