SwiftUI watch-only app in watch/, generated by XcodeGen. Codable models mirror gateway/src/contract.ts, with lenient string enums so new phases or decisions still decode. A DemoGateway serves fixtures from the docs/api.md examples for previews and demo mode. Swift Testing covers decoding the documented JSON, request encoding, notification action parsing and the small view helpers. Co-Authored-By: Claude Opus 5.5 <[email protected]>
95 lines
3.4 KiB
Swift
95 lines
3.4 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.
|
|
|
|
import Foundation
|
|
import Security
|
|
|
|
struct Credentials: Equatable, Sendable {
|
|
var gatewayURL: URL
|
|
var token: String
|
|
}
|
|
|
|
enum DeviceStore {
|
|
static let defaultGatewayURL = "https://wristcode.gbrown.org"
|
|
|
|
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
|
|
}
|
|
|
|
/// 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 read(_ account: String) -> String? {
|
|
var query = query(account)
|
|
query[kSecReturnData as String] = true
|
|
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
var result: CFTypeRef?
|
|
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
|
|
let data = result as? Data
|
|
else { return nil }
|
|
return String(data: data, encoding: .utf8)
|
|
}
|
|
|
|
private static func write(_ value: String, account: String) {
|
|
let data = Data(value.utf8)
|
|
let attributes: [String: Any] = [
|
|
kSecValueData as String: data,
|
|
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
|
]
|
|
let status = SecItemUpdate(query(account) as CFDictionary, attributes as CFDictionary)
|
|
if status == errSecItemNotFound {
|
|
let item = query(account).merging(attributes) { _, new in new }
|
|
SecItemAdd(item as CFDictionary, nil)
|
|
}
|
|
}
|
|
|
|
private static func delete(_ account: String) {
|
|
SecItemDelete(query(account) as CFDictionary)
|
|
}
|
|
}
|