Automatic signing with an App Store Connect API key registers bundle IDs and profiles but cannot create or assign App Groups, so the archive failed. A keychain-access-groups entry needs no portal setup. The demo flag and last summary move into the same group; tests cover the round trip and the migration of a pre-complication token. Co-Authored-By: Claude Opus 5.5 <[email protected]>
156 lines
6.7 KiB
Swift
156 lines
6.7 KiB
Swift
// The device token and gateway URL, kept in the Keychain. They are readable after first unlock, so
|
|
// a notification action handled in the background can still call the gateway.
|
|
//
|
|
// The items live in a Keychain access group the app and the complication share, so the complication
|
|
// can read them too. Builds before the complication kept them in the app's own access group;
|
|
// `migrateToSharedGroup()` moves them on launch, so a paired watch stays paired.
|
|
|
|
import Foundation
|
|
import Security
|
|
|
|
struct Credentials: Equatable, Sendable {
|
|
var gatewayURL: URL
|
|
var token: String
|
|
}
|
|
|
|
enum DeviceStore {
|
|
static let defaultGatewayURL = "https://wristcode.gbrown.org"
|
|
|
|
/// `<team>.org.gbrown.wristcode.shared`, from Info.plist, where the build fills in the team prefix.
|
|
static let accessGroup = Bundle.main.object(forInfoDictionaryKey: "WristCodeKeychainGroup") as? String
|
|
?? "GQJNM5YK2M.org.gbrown.wristcode.shared"
|
|
|
|
private static let service = "org.gbrown.wristcode"
|
|
private static let tokenAccount = "deviceToken"
|
|
private static let urlAccount = "gatewayURL"
|
|
|
|
static func load() -> Credentials? {
|
|
guard let token = read(tokenAccount),
|
|
let urlString = read(urlAccount),
|
|
let url = URL(string: urlString)
|
|
else { return nil }
|
|
return Credentials(gatewayURL: url, token: token)
|
|
}
|
|
|
|
static func save(_ credentials: Credentials) {
|
|
write(credentials.gatewayURL.absoluteString, account: urlAccount)
|
|
write(credentials.token, account: tokenAccount)
|
|
}
|
|
|
|
/// Forgets the token but keeps the URL, so the pairing screen is prefilled.
|
|
static func clearToken() {
|
|
delete(tokenAccount)
|
|
}
|
|
|
|
/// The last gateway URL used, for prefilling the pairing screen.
|
|
static var lastGatewayURL: String {
|
|
read(urlAccount) ?? defaultGatewayURL
|
|
}
|
|
|
|
/// Moves items an older build saved in the app's own access group into the shared one. Does
|
|
/// nothing once they have moved. If the move fails the old items stay, and `load()` still finds
|
|
/// them, so the app keeps working and only the complication goes without.
|
|
static func migrateToSharedGroup() {
|
|
for account in [tokenAccount, urlAccount] where readShared(account) == nil {
|
|
guard let legacy = readAnyGroup(account), legacy.group != accessGroup else { continue }
|
|
var item = query(account)
|
|
item[kSecAttrAccessGroup as String] = accessGroup
|
|
item[kSecValueData as String] = Data(legacy.value.utf8)
|
|
item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
|
guard SecItemAdd(item as CFDictionary, nil) == errSecSuccess else { continue }
|
|
var old = query(account)
|
|
old[kSecAttrAccessGroup as String] = legacy.group
|
|
SecItemDelete(old as CFDictionary)
|
|
}
|
|
}
|
|
|
|
/// Turns what someone typed into a gateway base URL: trims it, adds `https://` when there is
|
|
/// no scheme, and drops trailing slashes. Returns nil when it is not a usable URL.
|
|
static func normalizeGatewayURL(_ input: String) -> URL? {
|
|
var text = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !text.isEmpty else { return nil }
|
|
if !text.contains("://") { text = "https://" + text }
|
|
while text.hasSuffix("/") { text.removeLast() }
|
|
guard let components = URLComponents(string: text),
|
|
let scheme = components.scheme?.lowercased(),
|
|
scheme == "https" || scheme == "http",
|
|
let host = components.host, !host.isEmpty
|
|
else { return nil }
|
|
return components.url
|
|
}
|
|
|
|
// MARK: - Keychain
|
|
|
|
private static func query(_ account: String) -> [String: Any] {
|
|
[
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: service,
|
|
kSecAttrAccount as String: account,
|
|
]
|
|
}
|
|
|
|
private static func sharedQuery(_ account: String) -> [String: Any] {
|
|
query(account).merging([kSecAttrAccessGroup as String: accessGroup]) { _, new in new }
|
|
}
|
|
|
|
/// The shared item, or failing that one an older build left in the app's own group.
|
|
private static func read(_ account: String) -> String? {
|
|
readShared(account) ?? readAnyGroup(account)?.value
|
|
}
|
|
|
|
private static func readShared(_ account: String) -> String? {
|
|
sharedData(account).flatMap { String(data: $0, encoding: .utf8) }
|
|
}
|
|
|
|
/// An item in the shared group only. `SharedState` keeps its values here too.
|
|
static func sharedData(_ account: String) -> Data? {
|
|
var query = sharedQuery(account)
|
|
query[kSecReturnData as String] = true
|
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
var result: CFTypeRef?
|
|
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { return nil }
|
|
return result as? Data
|
|
}
|
|
|
|
/// Saves or, given nil, deletes an item in the shared group.
|
|
static func setSharedData(_ data: Data?, account: String) {
|
|
guard let data else {
|
|
SecItemDelete(sharedQuery(account) as CFDictionary)
|
|
return
|
|
}
|
|
let attributes: [String: Any] = [
|
|
kSecValueData as String: data,
|
|
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
|
]
|
|
let status = SecItemUpdate(sharedQuery(account) as CFDictionary, attributes as CFDictionary)
|
|
if status == errSecItemNotFound {
|
|
let item = sharedQuery(account).merging(attributes) { _, new in new }
|
|
SecItemAdd(item as CFDictionary, nil)
|
|
}
|
|
}
|
|
|
|
/// An item in any access group this process can read, with the group it is in.
|
|
private static func readAnyGroup(_ account: String) -> (value: String, group: String)? {
|
|
var query = query(account)
|
|
query[kSecReturnData as String] = true
|
|
query[kSecReturnAttributes as String] = true
|
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
var result: CFTypeRef?
|
|
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
|
let attributes = result as? [String: Any],
|
|
let data = attributes[kSecValueData as String] as? Data,
|
|
let value = String(data: data, encoding: .utf8)
|
|
else { return nil }
|
|
return (value, attributes[kSecAttrAccessGroup as String] as? String ?? "")
|
|
}
|
|
|
|
private static func write(_ value: String, account: String) {
|
|
setSharedData(Data(value.utf8), account: account)
|
|
}
|
|
|
|
/// Deletes the item from every access group, so a leftover from an older build goes too.
|
|
private static func delete(_ account: String) {
|
|
SecItemDelete(query(account) as CFDictionary)
|
|
}
|
|
}
|