A gear on the feed opens a status overview that leads with what is wrong: offline hosts, hosts on a fallback route, tokens expiring within 7 days, and push problems, with healthy hosts folded into one row. Each host opens its route ladder with Reconnect. The notifications page shows registration, the last push, Send test and Unpair. Demo mode gains a Laptop on its VPN fallback, and snapshot tests render the three screens.
445 lines
15 KiB
Swift
445 lines
15 KiB
Swift
// 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
|
|
/// When true, the answer is an array of option values.
|
|
var multiSelect = false
|
|
}
|
|
|
|
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"
|
|
}
|
|
}
|
|
}
|
|
|
|
extension PendingRequest.Question {
|
|
private enum CodingKeys: String, CodingKey {
|
|
case id, header, question, options, allowCustomAnswer, multiSelect
|
|
}
|
|
|
|
/// `multiSelect` is newer than the rest, so a gateway that leaves it out means single-select.
|
|
init(from decoder: any Decoder) throws {
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
self.init(
|
|
id: try container.decode(String.self, forKey: .id),
|
|
header: try container.decode(String.self, forKey: .header),
|
|
question: try container.decode(String.self, forKey: .question),
|
|
options: try container.decode([Choice].self, forKey: .options),
|
|
allowCustomAnswer: try container.decode(Bool.self, forKey: .allowCustomAnswer),
|
|
multiSelect: try container.decodeIfPresent(Bool.self, forKey: .multiSelect) ?? false)
|
|
}
|
|
}
|
|
|
|
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]
|
|
}
|
|
|
|
/// `GET /v1/summary`: the counts behind the complication.
|
|
struct Summary: Codable, Hashable, Sendable {
|
|
struct Next: Codable, Hashable, Sendable {
|
|
var hostId: String
|
|
var threadId: String
|
|
var title: String
|
|
var hostLabel: String
|
|
|
|
var route: Route { .thread(hostId: hostId, threadId: threadId) }
|
|
}
|
|
|
|
var needsYou: Int
|
|
var working: Int
|
|
/// The thread that has waited longest for you.
|
|
var next: Next?
|
|
var hostsOffline: Int
|
|
}
|
|
|
|
extension Summary {
|
|
/// The same counts worked out from a feed. The feed lists needs-you oldest request first, so its
|
|
/// first item is the one that has waited longest.
|
|
init(_ feed: Feed) {
|
|
let next = feed.needsYou.first
|
|
self.init(
|
|
needsYou: feed.needsYou.count,
|
|
working: feed.working.count,
|
|
next: next.map { Next(hostId: $0.hostId, threadId: $0.threadId, title: $0.title, hostLabel: $0.hostLabel) },
|
|
hostsOffline: feed.hosts.filter { $0.status != .online }.count)
|
|
}
|
|
}
|
|
|
|
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]
|
|
}
|
|
|
|
/// How a route reaches its host, guessed by the gateway from the URL.
|
|
struct RouteKind: RawRepresentable, Codable, Hashable, Sendable {
|
|
let rawValue: String
|
|
init(rawValue: String) { self.rawValue = rawValue }
|
|
|
|
static let direct = RouteKind(rawValue: "direct")
|
|
static let vpn = RouteKind(rawValue: "vpn")
|
|
static let `public` = RouteKind(rawValue: "public")
|
|
|
|
/// "direct", "VPN" or "public", for sentences like "Laptop is on its VPN route".
|
|
var name: String { self == .vpn ? "VPN" : rawValue }
|
|
|
|
var symbol: String {
|
|
switch self {
|
|
case .direct: "house.fill"
|
|
case .vpn: "lock.shield.fill"
|
|
default: "cloud.fill"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One way to reach a host. `Route` in the contract, renamed here because `Route` is the app's
|
|
/// navigation.
|
|
struct HostRoute: Codable, Hashable, Sendable {
|
|
struct Result: RawRepresentable, Codable, Hashable, Sendable {
|
|
let rawValue: String
|
|
init(rawValue: String) { self.rawValue = rawValue }
|
|
|
|
static let ok = Result(rawValue: "ok")
|
|
static let failed = Result(rawValue: "failed")
|
|
static let untried = Result(rawValue: "untried")
|
|
}
|
|
|
|
var url: String
|
|
var kind: RouteKind
|
|
/// From the latest connect. `untried` when an earlier route answered first.
|
|
var result: Result
|
|
var error: String?
|
|
var checkedAt: Date?
|
|
|
|
/// The URL without its scheme, such as `laptop.gib:3773`.
|
|
var hostPort: String {
|
|
guard let components = URLComponents(string: url), let host = components.host else { return url }
|
|
return components.port.map { "\(host):\($0)" } ?? host
|
|
}
|
|
}
|
|
|
|
/// One host as the status screens show it.
|
|
struct HostStatusDetail: Codable, Hashable, Sendable, Identifiable {
|
|
var id: String
|
|
var label: String
|
|
var status: HostStatus
|
|
var error: String?
|
|
/// The route in use. Null unless online.
|
|
var activeUrl: String?
|
|
/// Preferred first, the order the gateway tries them.
|
|
var routes: [HostRoute]
|
|
var onlineSince: Date?
|
|
var lastOnlineAt: Date?
|
|
/// When the gateway tries again, while it waits to retry.
|
|
var nextRetryAt: Date?
|
|
var tokenExpiresAt: Date?
|
|
var serverVersion: String?
|
|
|
|
var activeRoute: HostRoute? { routes.first { $0.url == activeUrl } }
|
|
|
|
/// Online, but not on the first route.
|
|
var onFallback: Bool {
|
|
status == .online && activeUrl != nil && activeUrl != routes.first?.url
|
|
}
|
|
}
|
|
|
|
/// What Apple answered for one push.
|
|
struct PushResult: Codable, Hashable, Sendable {
|
|
var ok: Bool
|
|
var at: Date
|
|
/// Apple's HTTP status, or 0 when the push never reached Apple.
|
|
var status: Int
|
|
/// Apple's reason, such as `BadDeviceToken`, or why the gateway could not send.
|
|
var reason: String?
|
|
}
|
|
|
|
/// `GET /v1/status`: everything the settings screens show.
|
|
struct StatusResponse: Codable, Hashable, Sendable {
|
|
struct Gateway: Codable, Hashable, Sendable {
|
|
var startedAt: Date
|
|
/// False when the gateway has no APNs key, so nothing can be pushed.
|
|
var pushConfigured: Bool
|
|
var lastPush: PushResult?
|
|
}
|
|
|
|
/// The watch asking.
|
|
struct Device: Codable, Hashable, Sendable {
|
|
var name: String
|
|
var pairedAt: Date
|
|
/// True once the watch has sent its APNs token.
|
|
var pushRegistered: Bool
|
|
var apnsEnvironment: PushRegistration.Environment?
|
|
}
|
|
|
|
var hosts: [HostStatusDetail]
|
|
var gateway: Gateway
|
|
var device: Device
|
|
}
|
|
|
|
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 {
|
|
/// One question's answer: an option value or typed text, or option values for a multi-select question.
|
|
enum Answer: Encodable, Hashable, Sendable {
|
|
case text(String)
|
|
case values([String])
|
|
|
|
func encode(to encoder: any Encoder) throws {
|
|
var container = encoder.singleValueContainer()
|
|
switch self {
|
|
case let .text(text): try container.encode(text)
|
|
case let .values(values): try container.encode(values)
|
|
}
|
|
}
|
|
}
|
|
|
|
case decision(requestId: String, decision: ApprovalDecision)
|
|
case answers(requestId: String, answers: [String: Answer])
|
|
|
|
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, test
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|