diff --git a/Calendarr iOS/Calendarr iOS.entitlements b/Calendarr iOS/Calendarr iOS.entitlements
index bb88d59..5a5e5d8 100644
--- a/Calendarr iOS/Calendarr iOS.entitlements
+++ b/Calendarr iOS/Calendarr iOS.entitlements
@@ -6,5 +6,15 @@
group.com.scarriffleservices.calendarr
+
+ keychain-access-groups
+
+ $(AppIdentifierPrefix)com.scarriffleservices.calendarr.ios
+ $(AppIdentifierPrefix)com.scarriffleservices.calendarr.shared
+
diff --git a/Calendarr iOS/CalendarrApp.swift b/Calendarr iOS/CalendarrApp.swift
index fe39d08..ede36bf 100644
--- a/Calendarr iOS/CalendarrApp.swift
+++ b/Calendarr iOS/CalendarrApp.swift
@@ -1,5 +1,4 @@
import SwiftUI
-import Security
@main
struct CalendarrApp: App {
@@ -25,17 +24,45 @@ class AppState {
init() {
serverURL = UserDefaults.standard.string(forKey: "serverURL") ?? ""
- // Migrate a token previously kept in UserDefaults into the Keychain once,
- // so existing logins survive the change without re-authenticating.
- if let legacy = UserDefaults.standard.string(forKey: "authToken"), !legacy.isEmpty {
- Keychain.set(legacy, for: "authToken")
- UserDefaults.standard.removeObject(forKey: "authToken")
- }
- authToken = Keychain.get("authToken") ?? ""
+ 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()) }
@@ -46,7 +73,7 @@ class AppState {
authToken = token
username = user
isAdmin = admin
- Keychain.set(token, for: "authToken") // secret → Keychain, not UserDefaults
+ try? KeychainStore.set(token, for: "authToken") // secret → Keychain, not UserDefaults
UserDefaults.standard.set(user, forKey: "username")
UserDefaults.standard.set(admin, forKey: "isAdmin")
}
@@ -55,10 +82,14 @@ class AppState {
authToken = ""
username = ""
isAdmin = false
- Keychain.set(nil, for: "authToken")
- UserDefaults.standard.removeObject(forKey: "authToken") // clear any legacy copy
+ 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() {
@@ -67,37 +98,3 @@ class AppState {
UserDefaults.standard.removeObject(forKey: "serverURL")
}
}
-
-/// Minimal Keychain wrapper for secrets (the auth bearer token). Values are
-/// stored as generic passwords, accessible after first unlock.
-enum Keychain {
- private static let service = "Calendarr"
-
- static func set(_ value: String?, for key: String) {
- let base: [String: Any] = [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrService as String: service,
- kSecAttrAccount as String: key,
- ]
- SecItemDelete(base as CFDictionary)
- guard let value, let data = value.data(using: .utf8) else { return }
- var add = base
- add[kSecValueData as String] = data
- add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
- SecItemAdd(add as CFDictionary, nil)
- }
-
- static func get(_ key: String) -> String? {
- let query: [String: Any] = [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrService as String: service,
- kSecAttrAccount as String: key,
- kSecReturnData as String: true,
- kSecMatchLimit as String: kSecMatchLimitOne,
- ]
- var out: AnyObject?
- guard SecItemCopyMatching(query as CFDictionary, &out) == errSecSuccess,
- let data = out as? Data else { return nil }
- return String(data: data, encoding: .utf8)
- }
-}
diff --git a/Calendarr iOS/Services/KeychainStore.swift b/Calendarr iOS/Services/KeychainStore.swift
new file mode 100644
index 0000000..f5faf98
--- /dev/null
+++ b/Calendarr iOS/Services/KeychainStore.swift
@@ -0,0 +1,131 @@
+import Foundation
+import Security
+
+/// Keychain access for secrets (currently just the auth bearer token).
+///
+/// This replaces the earlier `enum Keychain`, which discarded every `OSStatus`.
+/// That mattered more than it looks: on Mac Catalyst a missing
+/// `keychain-access-groups` entitlement makes `SecItemAdd`/`SecItemCopyMatching`
+/// fail with `errSecMissingEntitlement`, and because nothing checked the status
+/// the result was indistinguishable from "no token stored" — the user was
+/// silently signed out on every launch.
+///
+/// Items live in a shared access group so the Mac build and other apps from the
+/// same team can read the token. Reading requires `keychain-access-groups` in
+/// the entitlements listing `sharedAccessGroup`.
+enum KeychainStore {
+ /// Access group shared across our apps. Must match an entry in the
+ /// `keychain-access-groups` entitlement of every app that uses it.
+ /// The `PP34X97WS3.` prefix is this account's Team ID — the same value
+ /// `$(AppIdentifierPrefix)` expands to at build time.
+ static let sharedAccessGroup = "PP34X97WS3.com.scarriffleservices.calendarr.shared"
+
+ private static let service = "Calendarr"
+
+ /// Set when a keychain call failed because the entitlement was missing and
+ /// we fell back to the app's own default access group. Signing is a build
+ /// configuration concern, so in DEBUG we make it loud; in release we keep
+ /// working rather than locking the user out of their own account.
+ private(set) nonisolated(unsafe) static var didFallBackToDefaultGroup = false
+
+ // MARK: - Public API
+
+ /// Store or delete a value. Passing `nil` deletes it.
+ static func set(_ value: String?, for key: String,
+ accessGroup: String? = sharedAccessGroup) throws {
+ do {
+ try write(value, key: key, accessGroup: accessGroup)
+ } catch KeychainError.status(errSecMissingEntitlement, _) where accessGroup != nil {
+ noteEntitlementFallback()
+ try write(value, key: key, accessGroup: nil)
+ }
+ }
+
+ /// Read a value. Returns `nil` when the item simply is not there;
+ /// throws when the keychain itself refused the request.
+ static func get(_ key: String, accessGroup: String? = sharedAccessGroup) throws -> String? {
+ do {
+ return try read(key: key, accessGroup: accessGroup)
+ } catch KeychainError.status(errSecMissingEntitlement, _) where accessGroup != nil {
+ noteEntitlementFallback()
+ return try read(key: key, accessGroup: nil)
+ }
+ }
+
+ // MARK: - Implementation
+
+ private static func baseQuery(key: String, accessGroup: String?) -> [String: Any] {
+ var q: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: key,
+ // Selects the modern, entitlement-gated keychain on macOS/Catalyst
+ // instead of the legacy file-based login keychain. Documented no-op
+ // on iOS 13+, so it is safe to set unconditionally.
+ kSecUseDataProtectionKeychain as String: true,
+ ]
+ if let accessGroup { q[kSecAttrAccessGroup as String] = accessGroup }
+ return q
+ }
+
+ private static func write(_ value: String?, key: String, accessGroup: String?) throws {
+ let base = baseQuery(key: key, accessGroup: accessGroup)
+
+ let deleteStatus = SecItemDelete(base as CFDictionary)
+ guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
+ throw KeychainError.status(deleteStatus, operation: "delete \(key)")
+ }
+
+ guard let value, let data = value.data(using: .utf8) else { return }
+
+ var add = base
+ add[kSecValueData as String] = data
+ add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
+
+ let addStatus = SecItemAdd(add as CFDictionary, nil)
+ guard addStatus == errSecSuccess else {
+ throw KeychainError.status(addStatus, operation: "add \(key)")
+ }
+ }
+
+ private static func read(key: String, accessGroup: String?) throws -> String? {
+ var query = baseQuery(key: key, accessGroup: accessGroup)
+ query[kSecReturnData as String] = true
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
+
+ var out: AnyObject?
+ let status = SecItemCopyMatching(query as CFDictionary, &out)
+ switch status {
+ case errSecSuccess:
+ guard let data = out as? Data else { return nil }
+ return String(data: data, encoding: .utf8)
+ case errSecItemNotFound:
+ return nil
+ default:
+ throw KeychainError.status(status, operation: "read \(key)")
+ }
+ }
+
+ private static func noteEntitlementFallback() {
+ guard !didFallBackToDefaultGroup else { return }
+ didFallBackToDefaultGroup = true
+ assertionFailure("""
+ Keychain access group \(sharedAccessGroup) was refused \
+ (errSecMissingEntitlement). Add it to keychain-access-groups and \
+ enable Keychain Sharing on the App ID. Falling back to the app's \
+ own default group — the Mac app will not see this token.
+ """)
+ }
+}
+
+enum KeychainError: Error, LocalizedError {
+ case status(OSStatus, operation: String)
+
+ var errorDescription: String? {
+ switch self {
+ case let .status(status, operation):
+ let detail = SecCopyErrorMessageString(status, nil) as String? ?? "OSStatus \(status)"
+ return "Keychain \(operation) failed: \(detail)"
+ }
+ }
+}
diff --git a/Shared/WidgetData.swift b/Shared/WidgetData.swift
index a623aed..b78bcf6 100644
--- a/Shared/WidgetData.swift
+++ b/Shared/WidgetData.swift
@@ -152,6 +152,17 @@ enum WidgetStore {
return (try? JSONDecoder().decode([WidgetCalendar].self, from: data)) ?? []
}
+ /// Drop the cached snapshot and calendar list. Called on logout and on
+ /// server reset: without this the files survive, and widgets — plus any
+ /// other app reading the group container — keep rendering the previous
+ /// user's events indefinitely.
+ static func clear() {
+ for url in [cacheURL, calendarsURL].compactMap({ $0 }) {
+ try? FileManager.default.removeItem(at: url)
+ }
+ WidgetTimelineNotifier.reload()
+ }
+
/// Rewrite the existing snapshot with the latest colour / language values
/// from UserDefaults. Used when the user tweaks an appearance setting and
/// we want the widgets to refresh immediately, without needing a new event