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]>
This commit is contained in:
1 parent
ba1624eb9e
commit
bdfb79b891
19 files changed
+2413
No files matched your search
@@ -0,0 +1,94 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user