Compare commits
2 Commits
554ad425b1
...
c9bd42aae5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9bd42aae5 | ||
|
|
781ccd1752 |
@@ -314,12 +314,10 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.5;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.5;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.5;
|
MACOSX_DEPLOYMENT_TARGET = 26.5;
|
||||||
MARKETING_VERSION = 1.0.1;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.local.scarriffle.Calendarr-iOSTests";
|
PRODUCT_BUNDLE_IDENTIFIER = "com.local.scarriffle.Calendarr-iOSTests";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
@@ -341,12 +339,10 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.5;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.5;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.5;
|
MACOSX_DEPLOYMENT_TARGET = 26.5;
|
||||||
MARKETING_VERSION = 1.0.1;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.local.scarriffle.Calendarr-iOSTests";
|
PRODUCT_BUNDLE_IDENTIFIER = "com.local.scarriffle.Calendarr-iOSTests";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
@@ -415,7 +411,9 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
CURRENT_PROJECT_VERSION = 7;
|
||||||
|
MARKETING_VERSION = 3.5;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
@@ -473,7 +471,9 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
CURRENT_PROJECT_VERSION = 7;
|
||||||
|
MARKETING_VERSION = 3.5;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
@@ -491,7 +491,6 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 6;
|
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -512,7 +511,6 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 3.5;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
||||||
PRODUCT_NAME = "Calendarr iOS";
|
PRODUCT_NAME = "Calendarr iOS";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -536,7 +534,6 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
|
CODE_SIGN_ENTITLEMENTS = "Calendarr iOS/Calendarr iOS.entitlements";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 6;
|
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -557,7 +554,6 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 3.5;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios;
|
||||||
PRODUCT_NAME = "Calendarr iOS";
|
PRODUCT_NAME = "Calendarr iOS";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -580,19 +576,17 @@
|
|||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = CalendarrWidgets/CalendarrWidgets.entitlements;
|
CODE_SIGN_ENTITLEMENTS = CalendarrWidgets/CalendarrWidgets.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = CalendarrWidgets/Info.plist;
|
INFOPLIST_FILE = CalendarrWidgets/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Calendarr Widgets";
|
INFOPLIST_KEY_CFBundleDisplayName = "Calendarr Widgets";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.CalendarrWidgets;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.CalendarrWidgets;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -615,19 +609,17 @@
|
|||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = CalendarrWidgets/CalendarrWidgets.entitlements;
|
CODE_SIGN_ENTITLEMENTS = CalendarrWidgets/CalendarrWidgets.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
|
||||||
DEVELOPMENT_TEAM = PP34X97WS3;
|
DEVELOPMENT_TEAM = PP34X97WS3;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = CalendarrWidgets/Info.plist;
|
INFOPLIST_FILE = CalendarrWidgets/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = "Calendarr Widgets";
|
INFOPLIST_KEY_CFBundleDisplayName = "Calendarr Widgets";
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "© 2026 Scarriffleservices";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.1;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.CalendarrWidgets;
|
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffleservices.calendarr.ios.CalendarrWidgets;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
|||||||
@@ -6,5 +6,15 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>group.com.scarriffleservices.calendarr</string>
|
<string>group.com.scarriffleservices.calendarr</string>
|
||||||
</array>
|
</array>
|
||||||
|
<!-- Order matters. Without this entitlement the default access group is
|
||||||
|
<AppIdentifierPrefix><bundle id>, which is where every existing token
|
||||||
|
lives. Adding the entitlement makes the FIRST entry the new default, so
|
||||||
|
listing our own group first keeps those tokens resolving and nobody
|
||||||
|
gets signed out. The .shared group is the one the Mac app also reads. -->
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)com.scarriffleservices.calendarr.ios</string>
|
||||||
|
<string>$(AppIdentifierPrefix)com.scarriffleservices.calendarr.shared</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import Security
|
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct CalendarrApp: App {
|
struct CalendarrApp: App {
|
||||||
@@ -25,17 +24,45 @@ class AppState {
|
|||||||
|
|
||||||
init() {
|
init() {
|
||||||
serverURL = UserDefaults.standard.string(forKey: "serverURL") ?? ""
|
serverURL = UserDefaults.standard.string(forKey: "serverURL") ?? ""
|
||||||
// Migrate a token previously kept in UserDefaults into the Keychain once,
|
authToken = Self.loadToken()
|
||||||
// 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") ?? ""
|
|
||||||
username = UserDefaults.standard.string(forKey: "username") ?? ""
|
username = UserDefaults.standard.string(forKey: "username") ?? ""
|
||||||
isAdmin = UserDefaults.standard.bool(forKey: "isAdmin")
|
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) {
|
func saveServer(url: String) {
|
||||||
serverURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
|
serverURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if serverURL.hasSuffix("/") { serverURL = String(serverURL.dropLast()) }
|
if serverURL.hasSuffix("/") { serverURL = String(serverURL.dropLast()) }
|
||||||
@@ -46,7 +73,7 @@ class AppState {
|
|||||||
authToken = token
|
authToken = token
|
||||||
username = user
|
username = user
|
||||||
isAdmin = admin
|
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(user, forKey: "username")
|
||||||
UserDefaults.standard.set(admin, forKey: "isAdmin")
|
UserDefaults.standard.set(admin, forKey: "isAdmin")
|
||||||
}
|
}
|
||||||
@@ -55,10 +82,14 @@ class AppState {
|
|||||||
authToken = ""
|
authToken = ""
|
||||||
username = ""
|
username = ""
|
||||||
isAdmin = false
|
isAdmin = false
|
||||||
Keychain.set(nil, for: "authToken")
|
try? KeychainStore.set(nil, for: "authToken")
|
||||||
UserDefaults.standard.removeObject(forKey: "authToken") // clear any legacy copy
|
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: "username")
|
||||||
UserDefaults.standard.removeObject(forKey: "isAdmin")
|
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() {
|
func resetServer() {
|
||||||
@@ -67,37 +98,3 @@ class AppState {
|
|||||||
UserDefaults.standard.removeObject(forKey: "serverURL")
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
131
Calendarr iOS/Services/KeychainStore.swift
Normal file
131
Calendarr iOS/Services/KeychainStore.swift
Normal file
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -152,6 +152,17 @@ enum WidgetStore {
|
|||||||
return (try? JSONDecoder().decode([WidgetCalendar].self, from: data)) ?? []
|
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
|
/// Rewrite the existing snapshot with the latest colour / language values
|
||||||
/// from UserDefaults. Used when the user tweaks an appearance setting and
|
/// from UserDefaults. Used when the user tweaks an appearance setting and
|
||||||
/// we want the widgets to refresh immediately, without needing a new event
|
/// we want the widgets to refresh immediately, without needing a new event
|
||||||
|
|||||||
Reference in New Issue
Block a user