Files
gib edc4b88990
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 22s
Watch: snooze finished threads, name the files a file approval would edit, Settle on finished notifications, an instant launch from the saved feed, plain Loading states, and no more cancelled errors
Gateway: an approval push for a file change says which files it would edit
2026-11-02 14:45:34 -05:00

896 lines
33 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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]
/// On `file-change` approvals, the files the agent wants to change. Older gateways leave it out,
/// which decodes as none.
var files: [PendingFile] = []
var isQuestion: Bool { !questions.isEmpty }
/// What a file-change approval would edit, for its card, thread screen and notification. Nil for
/// other requests and when the gateway names no files, which show the prompt instead.
var fileEdit: FileEdit? {
guard kind == "file-change" || kind == "file_change", !files.isEmpty else { return nil }
return FileEdit(files: files)
}
/// 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 {
private enum CodingKeys: String, CodingKey {
case id, kind, prompt, reason, options, questions, files
}
/// `files` is newer than the rest, so a gateway that leaves it out names no files.
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.init(
id: try container.decode(String.self, forKey: .id),
kind: try container.decode(String.self, forKey: .kind),
prompt: try container.decodeIfPresent(String.self, forKey: .prompt),
reason: try container.decodeIfPresent(String.self, forKey: .reason),
options: try container.decode([Option].self, forKey: .options),
questions: try container.decode([Question].self, forKey: .questions),
files: try container.decodeIfPresent([PendingFile].self, forKey: .files) ?? [])
}
}
/// A file a file-change approval would change. `name` is the last part of `path`. The gateway
/// usually has no diff before approval, so the counts are nil and `preview` is empty.
struct PendingFile: Codable, Hashable, Sendable, Identifiable {
var path: String
var name: String
var additions: Int?
var deletions: Int?
/// The first few changed lines, each starting with `+` or `-`.
var preview: [String]
var id: String { path }
/// "+9" and "−6", each nil when the gateway did not count it.
var counts: (additions: String?, deletions: String?) {
(additions.map { "+\($0)" }, deletions.map { "\u{2212}\($0)" })
}
}
/// The lines a file-change approval shows in place of its prompt: "Wants to edit" over one file, or
/// "Wants to edit 3 files" over a row for each. One file also shows its preview lines.
struct FileEdit: Hashable, Sendable {
/// A preview line and whether it adds or removes, which colors it green or red.
struct Line: Hashable, Sendable, Identifiable {
enum Change: Sendable { case added, removed, context }
var id: Int
var text: String
var change: Change
}
var files: [PendingFile]
var title: String { files.count == 1 ? "Wants to edit" : "Wants to edit \(files.count) files" }
/// The single file's first changed lines. Empty with several files, as their rows show no diff.
var preview: [Line] {
guard files.count == 1 else { return [] }
return files[0].preview.enumerated().map { index, text in
let change: Line.Change = text.hasPrefix("+") ? .added : text.hasPrefix("-") ? .removed : .context
return Line(id: index, text: text, change: change)
}
}
/// One line for a notification or the Smart Stack: "Wants to edit compose.yml" or "Wants to edit
/// 3 files: compose.yml, Containerfile, health.ts".
var sentence: String {
let names = files.map(\.name).joined(separator: ", ")
return files.count == 1 ? "\(title) \(names)" : "\(title): \(names)"
}
}
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 {
/// The subagent a request came from, when a thread delegated work and that work waits on you.
struct Via: Codable, Hashable, Sendable {
var hostId: String
var threadId: String
var title: String
}
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?
/// Set when `request` and `phase` belong to a subagent rather than this thread. Older gateways
/// leave it out, which decodes as nil.
var via: Via? = nil
/// What a working thread is doing right now. Nil when nothing is in progress, and from older
/// gateways.
var activity: Activity? = nil
/// What the latest run changed, in total. Nil when it changed no files, and from older gateways.
var changes: ChangeTotals? = nil
/// When a snoozed thread comes back to Recent. Nil when it is not snoozed, and from older gateways.
var snoozedUntil: Date? = nil
var id: String { "\(hostId)/\(threadId)" }
var route: Route { .thread(hostId: hostId, threadId: threadId) }
/// The thread that answers `request`: the subagent named in `via`, or this thread.
var requestThread: (hostId: String, threadId: String) {
via.map { ($0.hostId, $0.threadId) } ?? (hostId, threadId)
}
}
/// One step a thread is taking now, for the working card's activity line.
struct Activity: Codable, Hashable, Sendable {
struct Kind: RawRepresentable, Codable, Hashable, Sendable {
let rawValue: String
init(rawValue: String) { self.rawValue = rawValue }
static let command = Kind(rawValue: "command")
static let edit = Kind(rawValue: "edit")
static let search = Kind(rawValue: "search")
static let web = Kind(rawValue: "web")
static let subagent = Kind(rawValue: "subagent")
static let thinking = Kind(rawValue: "thinking")
static let tool = Kind(rawValue: "tool")
}
var kind: Kind
/// The command line, file name, search, subagent title or tool name, trimmed by the gateway.
var text: String
/// When the step started, for its elapsed time.
var startedAt: Date?
}
/// What a thread's latest run changed, in total, for the line on a Recent card.
struct ChangeTotals: Codable, Hashable, Sendable {
var files: Int
var additions: Int
var deletions: Int
/// "+42", "−8" and "3 files", the pieces of the card's "+42 −8 · 3 files".
var parts: (additions: String, deletions: String, files: String) {
("+\(additions)", "\u{2212}\(deletions)", files == 1 ? "1 file" : "\(files) files")
}
}
/// A file the latest run changed. `name` is the last part of `path`.
struct ChangedFile: Codable, Hashable, Sendable, Identifiable {
var path: String
var name: String
var additions: Int
var deletions: Int
var id: String { path }
}
/// A thread search hit: the thread, and the text around the match when it was inside a message.
struct SearchHit: Codable, Hashable, Sendable, Identifiable {
var thread: ThreadRow
var projectTitle: String
var hostLabel: String
/// Nil when only the title matched.
var snippet: String?
var id: String { thread.id }
}
/// `GET /v1/search?q=`: hits from every online host, newest match first.
struct SearchResponse: Codable, Hashable, Sendable {
var hits: [SearchHit]
/// Labels of hosts that were offline or failed, so their threads were not searched.
var unsearched: [String]
/// The line under the results naming the hosts left out, such as "Laptop is offline, so its
/// threads were not searched." `offline` holds the labels of hosts the watch knows are offline;
/// any other host failed or was too slow, which the gateway does not tell apart.
static func unsearchedNote(_ labels: [String], offline: Set<String>) -> String? {
guard !labels.isEmpty else { return nil }
let names: String = switch labels.count {
case 1: labels[0]
case 2: "\(labels[0]) and \(labels[1])"
default: labels.dropLast().joined(separator: ", ") + " and " + labels[labels.count - 1]
}
guard labels.allSatisfy(offline.contains) else { return "\(names) could not be searched." }
return labels.count == 1
? "\(names) is offline, so its threads were not searched."
: "\(names) are offline, so their threads were not searched."
}
}
/// A thread in a list: a project's threads, or a thread's subagents.
struct ThreadRow: Codable, Hashable, Sendable, Identifiable {
var hostId: String
var threadId: String
var title: String
/// Nil for a thread that never ran or was stopped.
var phase: Phase?
var settled: Bool
var updatedAt: Date
var id: String { "\(hostId)/\(threadId)" }
var route: Route { .thread(hostId: hostId, threadId: threadId) }
/// "working", "waiting", "settled", "failed" or "done", for a subagent's row.
var statusWord: String {
if phase?.isWorking == true { return "working" }
if phase?.needsYou == true { return "waiting" }
if settled { return "settled" }
return phase == .failed ? "failed" : "done"
}
/// False once it finished or settled, which dims its title.
var isActive: Bool { !settled && (phase?.isWorking == true || phase?.needsYou == true) }
}
/// `GET /v1/projects/:hostId/:projectId/threads`: a project's newest 50 threads, newest first.
struct ThreadListResponse: Codable, Hashable, Sendable {
var items: [ThreadRow]
}
/// `GET /v1/threads/:hostId/:threadId/history`: the page before the messages the watch has.
struct HistoryResponse: Codable, Hashable, Sendable {
/// Oldest first.
var messages: [Message]
/// The cursor for the page before these. Nil at the start of the thread.
var earlierCursor: String?
}
struct Feed: Codable, Hashable, Sendable {
var needsYou: [FeedItem]
var working: [FeedItem]
/// Finished or failed threads not settled yet, newest first and at most 10.
var recent: [FeedItem]
/// How many threads Recent holds in all. `GET /v1/recent` lists the ones past `recent`.
var recentTotal: Int
/// Finished threads snoozed for later, which `GET /v1/snoozed` lists.
var snoozedTotal: Int
var hosts: [HostSummary]
/// `recentTotal` defaults to the threads in `recent`, as when nothing is left out.
init(
needsYou: [FeedItem], working: [FeedItem], recent: [FeedItem], recentTotal: Int? = nil, snoozedTotal: Int = 0,
hosts: [HostSummary]
) {
self.needsYou = needsYou
self.working = working
self.recent = recent
self.recentTotal = recentTotal ?? recent.count
self.snoozedTotal = snoozedTotal
self.hosts = hosts
}
/// Recent threads the feed leaves out, which the "N more" row counts.
var moreRecent: Int { max(0, recentTotal - recent.count) }
}
extension Feed {
private enum CodingKeys: String, CodingKey {
case needsYou, working, recent, recentTotal, snoozedTotal, hosts
}
/// `recentTotal` and `snoozedTotal` are newer than the rest, so a gateway that leaves them out has
/// nothing past `recent` and nothing snoozed.
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.init(
needsYou: try container.decode([FeedItem].self, forKey: .needsYou),
working: try container.decode([FeedItem].self, forKey: .working),
recent: try container.decode([FeedItem].self, forKey: .recent),
recentTotal: try container.decodeIfPresent(Int.self, forKey: .recentTotal),
snoozedTotal: try container.decodeIfPresent(Int.self, forKey: .snoozedTotal) ?? 0,
hosts: try container.decode([HostSummary].self, forKey: .hosts))
}
}
/// `GET /v1/recent`: every unsettled finished thread, newest first. Older ones may lack `lastMessage`.
struct RecentResponse: Codable, Hashable, Sendable {
var items: [FeedItem]
}
/// `POST /v1/threads/:hostId/:threadId/snooze`. `until` is in the future and at most 30 days away.
struct SnoozeRequest: Codable, Sendable {
var until: Date
}
/// `POST /v1/recent/settle`. `failed` counts threads T3 refused or whose host was offline.
struct SettleRecentResponse: Codable, Hashable, Sendable {
var settled: Int
var failed: Int
}
/// `GET /v1/summary`: what the complication and the Smart Stack widget show.
struct Summary: Codable, Hashable, Sendable {
struct Next: Codable, Hashable, Sendable {
var hostId: String
var threadId: String
var title: String
var hostLabel: String
/// The command it wants to run or its first question, trimmed. Nil until the gateway has read
/// it, and from older gateways.
var request: String? = nil
var route: Route { .thread(hostId: hostId, threadId: threadId) }
}
/// The working thread updated most recently, with what it is doing.
struct Current: Codable, Hashable, Sendable {
var hostId: String
var threadId: String
var title: String
var activity: Activity?
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?
/// Nil when nothing is working, and from older gateways.
var current: Current? = nil
var hostsOffline: Int
}
extension Summary {
/// The same summary worked out from a feed. The feed lists needs-you oldest request first, so its
/// first item is the one that has waited longest, and working newest first.
init(_ feed: Feed) {
let next = feed.needsYou.first
let current = feed.working.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, request: $0.request?.summary)
},
current: current.map { Current(hostId: $0.hostId, threadId: $0.threadId, title: $0.title, activity: $0.activity) },
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
/// True when the thread asks before each action (T3's approval-required mode). Older gateways
/// leave it out, which decodes as nil, and the screen hides the switch.
var askFirst: Bool? = nil
/// Pass to the history route for older messages. Nil when `messages` starts the thread, and from
/// older gateways, which hides Load earlier.
var earlierCursor: String? = nil
/// Subagents and delegated threads this thread started, newest first.
var subagents: [ThreadRow] = []
/// The files the latest run changed, most lines changed first.
var changedFiles: [ChangedFile] = []
}
extension ThreadDetail {
private enum CodingKeys: String, CodingKey {
case item, messages, canStop, askFirst, earlierCursor, subagents, changedFiles
}
/// `subagents` and `changedFiles` are newer than the rest, so a gateway that leaves them out lists none.
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.init(
item: try container.decode(FeedItem.self, forKey: .item),
messages: try container.decode([Message].self, forKey: .messages),
canStop: try container.decode(Bool.self, forKey: .canStop),
askFirst: try container.decodeIfPresent(Bool.self, forKey: .askFirst),
earlierCursor: try container.decodeIfPresent(String.self, forKey: .earlierCursor),
subagents: try container.decodeIfPresent([ThreadRow].self, forKey: .subagents) ?? [],
changedFiles: try container.decodeIfPresent([ChangedFile].self, forKey: .changedFiles) ?? [])
}
}
/// `POST /v1/threads/:hostId/:threadId/mode`.
struct ThreadModeRequest: Codable, Sendable {
var askFirst: 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]
/// The providers that can start a thread now, each with its models. Empty from older
/// gateways, which only start threads on the project's default model.
var providers: [AgentProvider] = []
/// Projects a new thread can start in. A project without a default model is not offered.
var launchable: [Project] { projects.filter { $0.model != nil } }
func provider(_ instanceId: String) -> AgentProvider? {
providers.first { $0.instanceId == instanceId }
}
/// True when this host still offers the model.
func offers(_ model: ModelRef) -> Bool {
provider(model.instanceId)?.models.contains { $0.slug == model.slug } ?? false
}
}
struct Project: Codable, Hashable, Sendable, Identifiable {
var id: String
var title: String
/// The model a new thread uses by default, such as `claude-opus-5-5`.
var model: String?
/// The provider `model` belongs to. Older gateways leave it out.
var instanceId: String? = nil
/// The default model as a pick, when the gateway names its provider.
var defaultModel: ModelRef? {
guard let model, let instanceId else { return nil }
return ModelRef(instanceId: instanceId, slug: model)
}
}
var hosts: [Host]
}
extension ProjectsResponse.Host {
private enum CodingKeys: String, CodingKey {
case id, label, status, error, projects, providers
}
/// `providers` is newer than the rest, so a gateway that leaves it out offers no model list.
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.init(
id: try container.decode(String.self, forKey: .id),
label: try container.decode(String.self, forKey: .label),
status: try container.decode(HostStatus.self, forKey: .status),
error: try container.decodeIfPresent(String.self, forKey: .error),
projects: try container.decode([ProjectsResponse.Project].self, forKey: .projects),
providers: try container.decodeIfPresent([AgentProvider].self, forKey: .providers) ?? [])
}
}
/// An agent provider on a host, such as Claude or Codex, with the models a new thread can use.
struct AgentProvider: Codable, Hashable, Sendable, Identifiable {
struct Model: Codable, Hashable, Sendable, Identifiable {
var slug: String
/// Such as "Claude Opus 5.5".
var name: String
var id: String { slug }
}
var instanceId: String
/// Such as "Claude" or "Codex".
var name: String
/// False when the provider cannot run in approval-required mode, so Ask before acting is hidden.
var canAskFirst: Bool
var models: [Model]
var id: String { instanceId }
}
/// A model picked for a new thread: a slug and the provider instance it runs on.
struct ModelRef: Codable, Hashable, Sendable {
var instanceId: String
var slug: String
}
/// 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?
/// Which pushes this watch gets. Older gateways leave it out, and the screen hides the switches.
var notifications: NotificationPrefs? = nil
}
var hosts: [HostStatusDetail]
var gateway: Gateway
var device: Device
}
/// Which pushes a watch wants. `needsMe` covers approvals and questions on your own threads, and
/// `subagents` covers the same from subagents. Host problems and test pushes always go out.
struct NotificationPrefs: Codable, Hashable, Sendable {
/// One switch, named as `PUT /v1/device/notifications` names it.
enum Key: String, CaseIterable, Sendable {
case needsMe, finished, failed, subagents
var keyPath: WritableKeyPath<NotificationPrefs, Bool> {
switch self {
case .needsMe: \.needsMe
case .finished: \.finished
case .failed: \.failed
case .subagents: \.subagents
}
}
}
var needsMe: Bool
var finished: Bool
var failed: Bool
var subagents: Bool
subscript(key: Key) -> Bool {
get { self[keyPath: key.keyPath] }
set { self[keyPath: key.keyPath] = newValue }
}
}
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, Equatable, 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
/// A model from the host's `providers`. Nil uses the project's default.
var model: ModelRef? = nil
/// Starts the thread in approval-required mode. Nil or false uses the host's default mode.
var askFirst: Bool? = nil
}
struct NewThreadResponse: Codable, Sendable {
var hostId: String
var threadId: String
}
struct ErrorResponse: Codable, Sendable {
var error: String
}
/// `question` on an `input` push that asks one single-choice question: its options, so the
/// notification can offer each as a button. Answer with `[id: value]` on the respond route.
struct PushQuestion: Codable, Hashable, Sendable {
var id: String
/// At most 4.
var options: [PendingRequest.Question.Choice]
/// Adds a spoken or typed answer.
var allowCustomAnswer: Bool
/// The question in a push's `userInfo`, or nil when the push has none or it does not decode.
init?(userInfo: [AnyHashable: Any]) {
guard let object = userInfo["question"], JSONSerialization.isValidJSONObject(object),
let data = try? JSONSerialization.data(withJSONObject: object),
let question = try? JSON.decoder().decode(Self.self, from: data)
else { return nil }
self = question
}
init(id: String, options: [PendingRequest.Question.Choice], allowCustomAnswer: Bool) {
self.id = id
self.options = options
self.allowCustomAnswer = allowCustomAnswer
}
}
/// 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)
}
}