Files
wristcode/watch/WristCode/Auth/DeviceStore.swift
T
gibandClaude Opus 5.5 bdfb79b891 Watch app MVP: XcodeGen project, gateway client, pair, feed, thread, new thread, notifications
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]>
2026-11-01 02:33:15 -05:00

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)
}
}