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,152 @@
|
||||
// HTTPS + JSON calls to the WristCode gateway. docs/api.md lists every route.
|
||||
|
||||
import Foundation
|
||||
|
||||
enum GatewayError: LocalizedError, Equatable {
|
||||
/// 401: the device token is missing or revoked. The app shows the pairing screen.
|
||||
case unauthorized
|
||||
/// Any other non-2xx status, with the gateway's `{ "error": ... }` message when it sent one.
|
||||
case http(status: Int, message: String?)
|
||||
case transport(String)
|
||||
case decoding(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unauthorized:
|
||||
"This watch is no longer paired."
|
||||
case let .http(status, message):
|
||||
message ?? Self.defaultMessage(status)
|
||||
case let .transport(message):
|
||||
message
|
||||
case let .decoding(message):
|
||||
"The gateway sent something unexpected. \(message)"
|
||||
}
|
||||
}
|
||||
|
||||
/// True for 409, when the request was stale, such as an approval someone already answered.
|
||||
var isStale: Bool {
|
||||
if case .http(409, _) = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private static func defaultMessage(_ status: Int) -> String {
|
||||
switch status {
|
||||
case 404: "Not found."
|
||||
case 409: "Already handled."
|
||||
case 429: "Too many attempts. Wait a minute."
|
||||
case 503: "That host is offline."
|
||||
default: "The gateway returned \(status)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the watch asks of a gateway. `GatewayClient` talks to the real one and `DemoGateway`
|
||||
/// serves fixtures, so every screen works without a live gateway.
|
||||
protocol GatewayAPI: Sendable {
|
||||
func feed() async throws -> Feed
|
||||
func thread(hostId: String, threadId: String) async throws -> ThreadDetail
|
||||
func sendMessage(hostId: String, threadId: String, text: String) async throws
|
||||
func respond(hostId: String, threadId: String, request: RespondRequest) async throws
|
||||
func stop(hostId: String, threadId: String) async throws
|
||||
func projects() async throws -> ProjectsResponse
|
||||
func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse
|
||||
func registerPush(_ registration: PushRegistration) async throws
|
||||
}
|
||||
|
||||
struct GatewayClient: GatewayAPI {
|
||||
let baseURL: URL
|
||||
let token: String?
|
||||
var session: URLSession = .shared
|
||||
|
||||
/// `POST /v1/pair`. The only call made without a device token.
|
||||
static func pair(baseURL: URL, code: String, deviceName: String) async throws -> PairResponse {
|
||||
let client = GatewayClient(baseURL: baseURL, token: nil)
|
||||
return try await client.call("POST", ["v1", "pair"], body: PairRequest(code: code, deviceName: deviceName))
|
||||
}
|
||||
|
||||
func feed() async throws -> Feed {
|
||||
try await call("GET", ["v1", "feed"])
|
||||
}
|
||||
|
||||
func thread(hostId: String, threadId: String) async throws -> ThreadDetail {
|
||||
try await call("GET", ["v1", "threads", hostId, threadId])
|
||||
}
|
||||
|
||||
func sendMessage(hostId: String, threadId: String, text: String) async throws {
|
||||
try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "messages"], body: SendMessageRequest(text: text))
|
||||
}
|
||||
|
||||
func respond(hostId: String, threadId: String, request: RespondRequest) async throws {
|
||||
try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "respond"], body: request)
|
||||
}
|
||||
|
||||
func stop(hostId: String, threadId: String) async throws {
|
||||
try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "stop"], body: nil as SendMessageRequest?)
|
||||
}
|
||||
|
||||
func projects() async throws -> ProjectsResponse {
|
||||
try await call("GET", ["v1", "projects"])
|
||||
}
|
||||
|
||||
func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse {
|
||||
try await call("POST", ["v1", "threads"], body: request)
|
||||
}
|
||||
|
||||
func registerPush(_ registration: PushRegistration) async throws {
|
||||
try await callIgnoringBody("PUT", ["v1", "device", "push"], body: registration)
|
||||
}
|
||||
|
||||
// MARK: - Plumbing
|
||||
|
||||
private func call<Response: Decodable>(_ method: String, _ path: [String]) async throws -> Response {
|
||||
try await call(method, path, body: nil as SendMessageRequest?)
|
||||
}
|
||||
|
||||
private func call<Body: Encodable, Response: Decodable>(
|
||||
_ method: String, _ path: [String], body: Body?
|
||||
) async throws -> Response {
|
||||
let data = try await perform(method, path, body: body)
|
||||
do {
|
||||
return try JSON.decoder().decode(Response.self, from: data)
|
||||
} catch {
|
||||
throw GatewayError.decoding(String(describing: error))
|
||||
}
|
||||
}
|
||||
|
||||
private func callIgnoringBody<Body: Encodable>(_ method: String, _ path: [String], body: Body?) async throws {
|
||||
_ = try await perform(method, path, body: body)
|
||||
}
|
||||
|
||||
private func perform<Body: Encodable>(_ method: String, _ path: [String], body: Body?) async throws -> Data {
|
||||
// appending(component:) percent-encodes each segment, slashes included.
|
||||
let url = path.reduce(baseURL) { $0.appending(component: $1) }
|
||||
var request = URLRequest(url: url, timeoutInterval: 20)
|
||||
request.httpMethod = method
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
if let token {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
if let body {
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSON.encoder().encode(body)
|
||||
}
|
||||
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
do {
|
||||
(data, response) = try await session.data(for: request)
|
||||
} catch let error as URLError {
|
||||
throw GatewayError.transport(error.localizedDescription)
|
||||
}
|
||||
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw GatewayError.transport("No HTTP response.")
|
||||
}
|
||||
guard (200 ..< 300).contains(http.statusCode) else {
|
||||
if http.statusCode == 401 { throw GatewayError.unauthorized }
|
||||
let message = try? JSON.decoder().decode(ErrorResponse.self, from: data).error
|
||||
throw GatewayError.http(status: http.statusCode, message: message)
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
// Codable mirror of gateway/src/contract.ts. docs/api.md describes the same JSON for humans.
|
||||
// Change both sides together.
|
||||
//
|
||||
// String enums from the gateway (phase, host status, approval decision) are modeled as
|
||||
// RawRepresentable structs rather than Swift enums, so a value the watch does not know yet still
|
||||
// decodes instead of failing the whole feed.
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Phase: RawRepresentable, Codable, Hashable, Sendable {
|
||||
let rawValue: String
|
||||
init(rawValue: String) { self.rawValue = rawValue }
|
||||
|
||||
static let starting = Phase(rawValue: "starting")
|
||||
static let running = Phase(rawValue: "running")
|
||||
static let waitingForApproval = Phase(rawValue: "waiting_for_approval")
|
||||
static let waitingForInput = Phase(rawValue: "waiting_for_input")
|
||||
static let completed = Phase(rawValue: "completed")
|
||||
static let failed = Phase(rawValue: "failed")
|
||||
|
||||
var needsYou: Bool { self == .waitingForApproval || self == .waitingForInput }
|
||||
var isWorking: Bool { self == .starting || self == .running }
|
||||
}
|
||||
|
||||
struct HostStatus: RawRepresentable, Codable, Hashable, Sendable {
|
||||
let rawValue: String
|
||||
init(rawValue: String) { self.rawValue = rawValue }
|
||||
|
||||
static let connecting = HostStatus(rawValue: "connecting")
|
||||
static let online = HostStatus(rawValue: "online")
|
||||
static let offline = HostStatus(rawValue: "offline")
|
||||
static let authInvalid = HostStatus(rawValue: "auth_invalid")
|
||||
static let incompatible = HostStatus(rawValue: "incompatible")
|
||||
|
||||
/// A short label for a host that is not online, such as "offline" or "needs a new token".
|
||||
var problem: String? {
|
||||
switch self {
|
||||
case .online: nil
|
||||
case .connecting: "connecting"
|
||||
case .authInvalid: "needs a new token"
|
||||
case .incompatible: "incompatible"
|
||||
default: "offline"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ApprovalDecision: RawRepresentable, Codable, Hashable, Sendable {
|
||||
let rawValue: String
|
||||
init(rawValue: String) { self.rawValue = rawValue }
|
||||
|
||||
static let accept = ApprovalDecision(rawValue: "accept")
|
||||
static let acceptForSession = ApprovalDecision(rawValue: "acceptForSession")
|
||||
static let acceptAlways = ApprovalDecision(rawValue: "acceptAlways")
|
||||
static let decline = ApprovalDecision(rawValue: "decline")
|
||||
static let cancel = ApprovalDecision(rawValue: "cancel")
|
||||
}
|
||||
|
||||
struct HostSummary: Codable, Hashable, Sendable, Identifiable {
|
||||
var id: String
|
||||
var label: String
|
||||
var status: HostStatus
|
||||
var error: String?
|
||||
}
|
||||
|
||||
/// Something a thread is waiting on: an approval, or questions for the user.
|
||||
struct PendingRequest: Codable, Hashable, Sendable, Identifiable {
|
||||
struct Option: Codable, Hashable, Sendable {
|
||||
var decision: ApprovalDecision
|
||||
var label: String
|
||||
}
|
||||
|
||||
struct Question: Codable, Hashable, Sendable, Identifiable {
|
||||
struct Choice: Codable, Hashable, Sendable {
|
||||
var label: String
|
||||
var value: String
|
||||
}
|
||||
|
||||
var id: String
|
||||
var header: String
|
||||
var question: String
|
||||
var options: [Choice]
|
||||
var allowCustomAnswer: Bool
|
||||
}
|
||||
|
||||
var id: String
|
||||
/// `command`, `file-change`, `permission`, `user_input`, ...
|
||||
var kind: String
|
||||
var prompt: String?
|
||||
var reason: String?
|
||||
var options: [Option]
|
||||
var questions: [Question]
|
||||
|
||||
var isQuestion: Bool { !questions.isEmpty }
|
||||
|
||||
/// The Allow and Deny pair shown on a feed card, when the server offers both.
|
||||
var quickOptions: (allow: Option, deny: Option)? {
|
||||
guard let allow = options.first(where: { $0.decision == .accept }),
|
||||
let deny = options.first(where: { $0.decision == .decline })
|
||||
else { return nil }
|
||||
return (allow, deny)
|
||||
}
|
||||
|
||||
/// One line saying what the agent wants, for a feed card.
|
||||
var summary: String {
|
||||
if let question = questions.first { return question.question }
|
||||
if let prompt, !prompt.isEmpty { return prompt }
|
||||
if let reason, !reason.isEmpty { return reason }
|
||||
return switch kind {
|
||||
case "command": "Run a command"
|
||||
case "file-change", "file_change": "Change files"
|
||||
default: "Approve a request"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FeedItem: Codable, Hashable, Sendable, Identifiable {
|
||||
var hostId: String
|
||||
var hostLabel: String
|
||||
var threadId: String
|
||||
var projectId: String
|
||||
var projectTitle: String
|
||||
var title: String
|
||||
var phase: Phase
|
||||
var updatedAt: Date
|
||||
var lastMessage: String?
|
||||
var request: PendingRequest?
|
||||
|
||||
var id: String { "\(hostId)/\(threadId)" }
|
||||
var route: Route { .thread(hostId: hostId, threadId: threadId) }
|
||||
}
|
||||
|
||||
struct Feed: Codable, Hashable, Sendable {
|
||||
var needsYou: [FeedItem]
|
||||
var working: [FeedItem]
|
||||
var recent: [FeedItem]
|
||||
var hosts: [HostSummary]
|
||||
}
|
||||
|
||||
struct Message: Codable, Hashable, Sendable, Identifiable {
|
||||
struct Role: RawRepresentable, Codable, Hashable, Sendable {
|
||||
let rawValue: String
|
||||
init(rawValue: String) { self.rawValue = rawValue }
|
||||
static let user = Role(rawValue: "user")
|
||||
static let assistant = Role(rawValue: "assistant")
|
||||
static let system = Role(rawValue: "system")
|
||||
}
|
||||
|
||||
var id: String
|
||||
var role: Role
|
||||
var text: String
|
||||
var createdAt: Date
|
||||
}
|
||||
|
||||
struct ThreadDetail: Codable, Hashable, Sendable {
|
||||
var item: FeedItem
|
||||
/// Oldest first, at most 20.
|
||||
var messages: [Message]
|
||||
var canStop: Bool
|
||||
}
|
||||
|
||||
struct ProjectsResponse: Codable, Hashable, Sendable {
|
||||
struct Host: Codable, Hashable, Sendable, Identifiable {
|
||||
var id: String
|
||||
var label: String
|
||||
var status: HostStatus
|
||||
var error: String?
|
||||
var projects: [Project]
|
||||
|
||||
/// Projects a new thread can start in. A project without a default model is not offered.
|
||||
var launchable: [Project] { projects.filter { $0.model != nil } }
|
||||
}
|
||||
|
||||
struct Project: Codable, Hashable, Sendable, Identifiable {
|
||||
var id: String
|
||||
var title: String
|
||||
var model: String?
|
||||
}
|
||||
|
||||
var hosts: [Host]
|
||||
}
|
||||
|
||||
struct PairRequest: Codable, Sendable {
|
||||
var code: String
|
||||
var deviceName: String
|
||||
}
|
||||
|
||||
struct PairResponse: Codable, Sendable {
|
||||
var deviceId: String
|
||||
var deviceToken: String
|
||||
}
|
||||
|
||||
struct PushRegistration: Codable, Sendable {
|
||||
enum Environment: String, Codable, Sendable {
|
||||
case sandbox, production
|
||||
}
|
||||
|
||||
var apnsToken: String
|
||||
var apnsEnvironment: Environment
|
||||
}
|
||||
|
||||
struct SendMessageRequest: Codable, Sendable {
|
||||
var text: String
|
||||
}
|
||||
|
||||
enum RespondRequest: Encodable, Sendable {
|
||||
case decision(requestId: String, decision: ApprovalDecision)
|
||||
case answers(requestId: String, answers: [String: String])
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case requestId, decision, answers }
|
||||
|
||||
func encode(to encoder: any Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
switch self {
|
||||
case let .decision(requestId, decision):
|
||||
try container.encode(requestId, forKey: .requestId)
|
||||
try container.encode(decision, forKey: .decision)
|
||||
case let .answers(requestId, answers):
|
||||
try container.encode(requestId, forKey: .requestId)
|
||||
try container.encode(answers, forKey: .answers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NewThreadRequest: Codable, Sendable {
|
||||
var hostId: String
|
||||
var projectId: String
|
||||
var text: String
|
||||
}
|
||||
|
||||
struct NewThreadResponse: Codable, Sendable {
|
||||
var hostId: String
|
||||
var threadId: String
|
||||
}
|
||||
|
||||
struct ErrorResponse: Codable, Sendable {
|
||||
var error: String
|
||||
}
|
||||
|
||||
/// Notification categories the watch registers actions for.
|
||||
enum PushCategory: String, CaseIterable, Sendable {
|
||||
case approval, finished, failed, input, host
|
||||
}
|
||||
|
||||
enum JSON {
|
||||
static func decoder() -> JSONDecoder {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .custom { decoder in
|
||||
let container = try decoder.singleValueContainer()
|
||||
let string = try container.decode(String.self)
|
||||
guard let date = parseDate(string) else {
|
||||
throw DecodingError.dataCorruptedError(
|
||||
in: container, debugDescription: "Not an ISO 8601 date: \(string)")
|
||||
}
|
||||
return date
|
||||
}
|
||||
return decoder
|
||||
}
|
||||
|
||||
static func encoder() -> JSONEncoder {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .custom { date, encoder in
|
||||
var container = encoder.singleValueContainer()
|
||||
try container.encode(Date.ISO8601FormatStyle(includingFractionalSeconds: true).format(date))
|
||||
}
|
||||
return encoder
|
||||
}
|
||||
|
||||
/// Parses ISO 8601 timestamps with or without fractional seconds.
|
||||
static func parseDate(_ string: String) -> Date? {
|
||||
let withFraction = Date.ISO8601FormatStyle(includingFractionalSeconds: true)
|
||||
if let date = try? withFraction.parse(string) { return date }
|
||||
return try? Date.ISO8601FormatStyle().parse(string)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user