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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// App-wide state: whether the watch is paired, the navigation path, and the latest feed.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import UserNotifications
|
||||||
|
import WatchKit
|
||||||
|
|
||||||
|
enum Route: Hashable, Sendable {
|
||||||
|
case thread(hostId: String, threadId: String)
|
||||||
|
case newThread
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor @Observable
|
||||||
|
final class AppModel {
|
||||||
|
enum Mode: Equatable {
|
||||||
|
case unpaired
|
||||||
|
case paired(Credentials)
|
||||||
|
/// Serves fixtures from `DemoGateway`. Nothing leaves the watch.
|
||||||
|
case demo
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one instance the app delegate and notification handler share with the views.
|
||||||
|
static let shared = AppModel(launchArguments: ProcessInfo.processInfo.arguments)
|
||||||
|
|
||||||
|
var mode: Mode
|
||||||
|
var path: [Route] = []
|
||||||
|
private(set) var feed: Feed?
|
||||||
|
private(set) var feedError: String?
|
||||||
|
private var apnsToken: String?
|
||||||
|
private var refreshing = false
|
||||||
|
|
||||||
|
init(mode: Mode, feed: Feed? = nil, path: [Route] = []) {
|
||||||
|
self.mode = mode
|
||||||
|
self.feed = feed
|
||||||
|
self.path = path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `-demo` starts in demo mode. `-screen new` or `-thread <hostId>/<threadId>` opens that
|
||||||
|
/// screen, which is how the screenshots in watch/screenshots are taken.
|
||||||
|
convenience init(launchArguments args: [String]) {
|
||||||
|
let demo = args.contains("-demo")
|
||||||
|
let mode: Mode = demo ? .demo : DeviceStore.load().map(Mode.paired) ?? .unpaired
|
||||||
|
var path: [Route] = []
|
||||||
|
if demo, let i = args.firstIndex(of: "-screen"), i + 1 < args.count, args[i + 1] == "new" {
|
||||||
|
path = [.newThread]
|
||||||
|
}
|
||||||
|
if demo, let i = args.firstIndex(of: "-thread"), i + 1 < args.count {
|
||||||
|
let parts = args[i + 1].split(separator: "/", maxSplits: 1).map(String.init)
|
||||||
|
if parts.count == 2 { path = [.thread(hostId: parts[0], threadId: parts[1])] }
|
||||||
|
}
|
||||||
|
self.init(mode: mode, path: path)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isPaired: Bool {
|
||||||
|
if case .paired = mode { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var api: any GatewayAPI {
|
||||||
|
switch mode {
|
||||||
|
case let .paired(credentials):
|
||||||
|
GatewayClient(baseURL: credentials.gatewayURL, token: credentials.token)
|
||||||
|
case .demo:
|
||||||
|
DemoGateway.shared
|
||||||
|
case .unpaired:
|
||||||
|
GatewayClient(baseURL: URL(string: DeviceStore.defaultGatewayURL)!, token: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pairing
|
||||||
|
|
||||||
|
func pair(gatewayURL: URL, code: String) async throws {
|
||||||
|
let deviceName = WKInterfaceDevice.current().name
|
||||||
|
let response = try await GatewayClient.pair(baseURL: gatewayURL, code: code, deviceName: deviceName)
|
||||||
|
let credentials = Credentials(gatewayURL: gatewayURL, token: response.deviceToken)
|
||||||
|
DeviceStore.save(credentials)
|
||||||
|
mode = .paired(credentials)
|
||||||
|
feed = nil
|
||||||
|
feedError = nil
|
||||||
|
path = []
|
||||||
|
await enableNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
func startDemo() {
|
||||||
|
mode = .demo
|
||||||
|
feed = nil
|
||||||
|
feedError = nil
|
||||||
|
path = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Back to the pairing screen. A real token is forgotten; the gateway URL is kept.
|
||||||
|
func signOut() {
|
||||||
|
if isPaired { DeviceStore.clearToken() }
|
||||||
|
mode = .unpaired
|
||||||
|
feed = nil
|
||||||
|
feedError = nil
|
||||||
|
path = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The message to show for a failed call. A 401 sends the app back to pairing and returns nil.
|
||||||
|
func message(for error: any Error) -> String? {
|
||||||
|
if error is CancellationError { return nil }
|
||||||
|
if let error = error as? GatewayError, error == .unauthorized {
|
||||||
|
signOut()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return error.localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Feed
|
||||||
|
|
||||||
|
func refreshFeed() async {
|
||||||
|
guard mode != .unpaired, !refreshing else { return }
|
||||||
|
refreshing = true
|
||||||
|
defer { refreshing = false }
|
||||||
|
do {
|
||||||
|
feed = try await api.feed()
|
||||||
|
feedError = nil
|
||||||
|
} catch {
|
||||||
|
feedError = message(for: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func open(_ route: Route) {
|
||||||
|
path = [route]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Notifications
|
||||||
|
|
||||||
|
func enableNotifications() async {
|
||||||
|
guard isPaired else { return }
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
|
||||||
|
if granted {
|
||||||
|
WKApplication.shared().registerForRemoteNotifications()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func didRegister(apnsToken data: Data) {
|
||||||
|
apnsToken = data.map { String(format: "%02x", $0) }.joined()
|
||||||
|
Task { await sendPushToken() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendPushToken() async {
|
||||||
|
guard isPaired, let apnsToken else { return }
|
||||||
|
#if DEBUG
|
||||||
|
let environment = PushRegistration.Environment.sandbox
|
||||||
|
#else
|
||||||
|
let environment = PushRegistration.Environment.production
|
||||||
|
#endif
|
||||||
|
do {
|
||||||
|
try await api.registerPush(PushRegistration(apnsToken: apnsToken, apnsEnvironment: environment))
|
||||||
|
} catch {
|
||||||
|
_ = message(for: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func notificationArrived() {
|
||||||
|
Task { await refreshFeed() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AppModel {
|
||||||
|
/// A demo model with the fixture feed already loaded, for previews.
|
||||||
|
static func preview(path: [Route] = []) -> AppModel {
|
||||||
|
AppModel(mode: .demo, feed: DemoFixtures.previewFeed(), path: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "icon.png", "idiom" : "universal", "platform" : "watchos", "size" : "1024x1024" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
// Fixture data shaped like the docs/api.md examples, served by an in-memory gateway. Demo mode and
|
||||||
|
// SwiftUI previews use it, so every screen renders without a live gateway. It reacts to writes:
|
||||||
|
// answering an approval moves the thread to working, a reply restarts a finished thread, and so on.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
actor DemoGateway: GatewayAPI {
|
||||||
|
static let shared = DemoGateway()
|
||||||
|
|
||||||
|
private var items: [FeedItem]
|
||||||
|
private var messages: [String: [Message]]
|
||||||
|
private let hosts: [HostSummary]
|
||||||
|
private let projectHosts: [ProjectsResponse.Host]
|
||||||
|
private var nextId = 1
|
||||||
|
|
||||||
|
init(now: Date = .now) {
|
||||||
|
let fixtures = DemoFixtures(now: now)
|
||||||
|
items = fixtures.items
|
||||||
|
messages = fixtures.messages
|
||||||
|
hosts = fixtures.hosts
|
||||||
|
projectHosts = fixtures.projectHosts
|
||||||
|
}
|
||||||
|
|
||||||
|
func feed() async throws -> Feed {
|
||||||
|
let needsYou = items.filter(\.phase.needsYou).sorted { $0.updatedAt < $1.updatedAt }
|
||||||
|
let working = items.filter(\.phase.isWorking).sorted { $0.updatedAt > $1.updatedAt }
|
||||||
|
let recent = items.filter { $0.phase == .completed || $0.phase == .failed }
|
||||||
|
.sorted { $0.updatedAt > $1.updatedAt }
|
||||||
|
return Feed(needsYou: needsYou, working: working, recent: Array(recent.prefix(10)), hosts: hosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func thread(hostId: String, threadId: String) async throws -> ThreadDetail {
|
||||||
|
guard let item = item(hostId, threadId) else { throw GatewayError.http(status: 404, message: "Unknown thread.") }
|
||||||
|
return ThreadDetail(item: item, messages: messages[item.id] ?? [], canStop: item.phase.isWorking)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendMessage(hostId: String, threadId: String, text: String) async throws {
|
||||||
|
try update(hostId, threadId) { item in
|
||||||
|
append(Message(id: newId(), role: .user, text: text, createdAt: .now), to: item.id)
|
||||||
|
if !item.phase.isWorking {
|
||||||
|
item.phase = .running
|
||||||
|
item.lastMessage = "Working on it."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func respond(hostId: String, threadId: String, request: RespondRequest) async throws {
|
||||||
|
try update(hostId, threadId) { item in
|
||||||
|
guard item.request != nil else { throw GatewayError.http(status: 409, message: "Already answered.") }
|
||||||
|
item.request = nil
|
||||||
|
switch request {
|
||||||
|
case let .decision(_, decision) where decision == .decline || decision == .cancel:
|
||||||
|
item.phase = .completed
|
||||||
|
item.lastMessage = "OK, I won't run it. Tell me what to do instead."
|
||||||
|
default:
|
||||||
|
item.phase = .running
|
||||||
|
item.lastMessage = "Thanks. Continuing."
|
||||||
|
}
|
||||||
|
if let line = item.lastMessage {
|
||||||
|
append(Message(id: newId(), role: .assistant, text: line, createdAt: .now), to: item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop(hostId: String, threadId: String) async throws {
|
||||||
|
try update(hostId, threadId) { item in
|
||||||
|
item.phase = .completed
|
||||||
|
item.lastMessage = "Stopped."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func projects() async throws -> ProjectsResponse {
|
||||||
|
ProjectsResponse(hosts: projectHosts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse {
|
||||||
|
guard let host = projectHosts.first(where: { $0.id == request.hostId }),
|
||||||
|
let project = host.projects.first(where: { $0.id == request.projectId })
|
||||||
|
else { throw GatewayError.http(status: 404, message: "Unknown project.") }
|
||||||
|
let threadId = "demo-\(newId())"
|
||||||
|
let item = FeedItem(
|
||||||
|
hostId: host.id, hostLabel: host.label, threadId: threadId, projectId: project.id,
|
||||||
|
projectTitle: project.title, title: String(request.text.prefix(40)), phase: .starting,
|
||||||
|
updatedAt: .now, lastMessage: nil, request: nil)
|
||||||
|
items.append(item)
|
||||||
|
messages[item.id] = [Message(id: newId(), role: .user, text: request.text, createdAt: .now)]
|
||||||
|
return NewThreadResponse(hostId: host.id, threadId: threadId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerPush(_ registration: PushRegistration) async throws {}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func item(_ hostId: String, _ threadId: String) -> FeedItem? {
|
||||||
|
items.first { $0.hostId == hostId && $0.threadId == threadId }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func update(_ hostId: String, _ threadId: String, _ change: (inout FeedItem) throws -> Void) throws {
|
||||||
|
guard let index = items.firstIndex(where: { $0.hostId == hostId && $0.threadId == threadId }) else {
|
||||||
|
throw GatewayError.http(status: 404, message: "Unknown thread.")
|
||||||
|
}
|
||||||
|
try change(&items[index])
|
||||||
|
items[index].updatedAt = .now
|
||||||
|
}
|
||||||
|
|
||||||
|
private func append(_ message: Message, to key: String) {
|
||||||
|
messages[key, default: []].append(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func newId() -> String {
|
||||||
|
nextId += 1
|
||||||
|
return "m\(nextId)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DemoFixtures {
|
||||||
|
let items: [FeedItem]
|
||||||
|
let messages: [String: [Message]]
|
||||||
|
let hosts: [HostSummary]
|
||||||
|
let projectHosts: [ProjectsResponse.Host]
|
||||||
|
|
||||||
|
init(now: Date) {
|
||||||
|
func ago(_ minutes: Double) -> Date { now.addingTimeInterval(-minutes * 60) }
|
||||||
|
|
||||||
|
hosts = [
|
||||||
|
HostSummary(id: "mac-mini", label: "Mac mini", status: .online, error: nil),
|
||||||
|
HostSummary(id: "server-gib", label: "server-gib", status: .online, error: nil),
|
||||||
|
HostSummary(id: "littleman", label: "LittleMan", status: .offline, error: "Connection refused"),
|
||||||
|
]
|
||||||
|
|
||||||
|
let approval = FeedItem(
|
||||||
|
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-ocr", projectId: "p-bill",
|
||||||
|
projectTitle: "bill-tracker", title: "Receipt OCR", phase: .waitingForApproval,
|
||||||
|
updatedAt: ago(3), lastMessage: "Parser and tests are written. I want to run the suite.",
|
||||||
|
request: PendingRequest(
|
||||||
|
id: "r-ocr", kind: "command", prompt: "bun test --filter ocr", reason: nil,
|
||||||
|
options: [
|
||||||
|
.init(decision: .accept, label: "Allow"),
|
||||||
|
.init(decision: .acceptForSession, label: "Allow this session"),
|
||||||
|
.init(decision: .decline, label: "Deny"),
|
||||||
|
],
|
||||||
|
questions: []))
|
||||||
|
|
||||||
|
let question = FeedItem(
|
||||||
|
hostId: "server-gib", hostLabel: "server-gib", threadId: "t-db", projectId: "p-wc",
|
||||||
|
projectTitle: "wristcode", title: "Gateway storage", phase: .waitingForInput,
|
||||||
|
updatedAt: ago(1), lastMessage: "Two ways to store pairing codes.",
|
||||||
|
request: PendingRequest(
|
||||||
|
id: "r-db", kind: "user_input", prompt: nil, reason: nil, options: [],
|
||||||
|
questions: [
|
||||||
|
.init(
|
||||||
|
id: "q1", header: "Storage", question: "Hash pairing codes or store them plain?",
|
||||||
|
options: [.init(label: "Hash them", value: "hash"), .init(label: "Plain", value: "plain")],
|
||||||
|
allowCustomAnswer: true),
|
||||||
|
]))
|
||||||
|
|
||||||
|
let scaffold = FeedItem(
|
||||||
|
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-scaffold", projectId: "p-wc-mac",
|
||||||
|
projectTitle: "wristcode", title: "Watch app scaffold", phase: .running, updatedAt: ago(0.5),
|
||||||
|
lastMessage: "Writing ThreadListView.swift", request: nil)
|
||||||
|
|
||||||
|
let containerfile = FeedItem(
|
||||||
|
hostId: "server-gib", hostLabel: "server-gib", threadId: "t-container", projectId: "p-wc",
|
||||||
|
projectTitle: "wristcode", title: "Gateway Containerfile", phase: .running, updatedAt: ago(2),
|
||||||
|
lastMessage: "Running podman build", request: nil)
|
||||||
|
|
||||||
|
let nextjs = FeedItem(
|
||||||
|
hostId: "littleman", hostLabel: "LittleMan", threadId: "t-next", projectId: "p-site",
|
||||||
|
projectTitle: "gbrown.org", title: "Bump Next.js", phase: .completed, updatedAt: ago(62),
|
||||||
|
lastMessage: "Done. Next.js is on 17.1 and the build passes. Want me to commit it?", request: nil)
|
||||||
|
|
||||||
|
let cdn = FeedItem(
|
||||||
|
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-cdn", projectId: "p-cdn",
|
||||||
|
projectTitle: "cdn", title: "Fix CDN headers", phase: .failed, updatedAt: ago(140),
|
||||||
|
lastMessage: "cargo test failed: 2 tests in headers.rs", request: nil)
|
||||||
|
|
||||||
|
items = [approval, question, scaffold, containerfile, nextjs, cdn]
|
||||||
|
|
||||||
|
func m(_ id: String, _ role: Message.Role, _ minutes: Double, _ text: String) -> Message {
|
||||||
|
Message(id: id, role: role, text: text, createdAt: ago(minutes))
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
approval.id: [
|
||||||
|
m("a1", .user, 20, "Add OCR for receipts. Use the existing parser module."),
|
||||||
|
m("a2", .assistant, 12, "I'll add a receipt parser in src/ocr and tests beside it."),
|
||||||
|
m("a3", .assistant, 3, "Parser and tests are written. I want to run the suite."),
|
||||||
|
],
|
||||||
|
question.id: [
|
||||||
|
m("b1", .user, 10, "Add pairing codes to the gateway."),
|
||||||
|
m("b2", .assistant, 1, "Two ways to store pairing codes. Hashing is safer, plain is simpler to debug."),
|
||||||
|
],
|
||||||
|
scaffold.id: [
|
||||||
|
m("c1", .user, 4, "Scaffold the watch app with XcodeGen."),
|
||||||
|
m("c2", .assistant, 0.5, "Writing ThreadListView.swift"),
|
||||||
|
],
|
||||||
|
containerfile.id: [
|
||||||
|
m("d1", .user, 15, "Write a Containerfile for the gateway."),
|
||||||
|
m("d2", .assistant, 2, "Running podman build"),
|
||||||
|
],
|
||||||
|
nextjs.id: [
|
||||||
|
m("e1", .user, 90, "Bump Next.js to the latest release."),
|
||||||
|
m("e2", .assistant, 80, "Updating next and eslint-config-next, then running the build."),
|
||||||
|
m("e3", .assistant, 62, "Done. Next.js is on 17.1 and the build passes. Want me to commit it?"),
|
||||||
|
],
|
||||||
|
cdn.id: [
|
||||||
|
m("f1", .user, 150, "Fix the cache headers the CDN sends for images."),
|
||||||
|
m("f2", .assistant, 140, "cargo test failed: 2 tests in headers.rs"),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
projectHosts = [
|
||||||
|
.init(
|
||||||
|
id: "mac-mini", label: "Mac mini", status: .online, error: nil,
|
||||||
|
projects: [
|
||||||
|
.init(id: "p-wc-mac", title: "wristcode", model: "claude-opus-5-5"),
|
||||||
|
.init(id: "p-bill", title: "bill-tracker", model: "claude-sonnet-5-5"),
|
||||||
|
.init(id: "p-cdn", title: "cdn", model: "gpt-5-codex"),
|
||||||
|
.init(id: "p-scratch", title: "scratch", model: nil),
|
||||||
|
]),
|
||||||
|
.init(
|
||||||
|
id: "server-gib", label: "server-gib", status: .online, error: nil,
|
||||||
|
projects: [.init(id: "p-wc", title: "wristcode", model: "claude-opus-5-5")]),
|
||||||
|
.init(
|
||||||
|
id: "littleman", label: "LittleMan", status: .offline, error: "Connection refused",
|
||||||
|
projects: [.init(id: "p-site", title: "gbrown.org", model: "claude-opus-5-5")]),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A feed for previews, without going through the actor.
|
||||||
|
static func previewFeed(now: Date = .now) -> Feed {
|
||||||
|
let fixtures = DemoFixtures(now: now)
|
||||||
|
return Feed(
|
||||||
|
needsYou: fixtures.items.filter(\.phase.needsYou),
|
||||||
|
working: fixtures.items.filter(\.phase.isWorking),
|
||||||
|
recent: fixtures.items.filter { !$0.phase.needsYou && !$0.phase.isWorking },
|
||||||
|
hosts: fixtures.hosts)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>WristCode</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>$(MARKETING_VERSION)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>WKApplication</key>
|
||||||
|
<true/>
|
||||||
|
<key>WKWatchOnly</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// Notification categories and actions from docs/api.md, and what each action does. Allow, Deny
|
||||||
|
// and Reply run in the background against the gateway; tapping a notification opens its thread.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import UserNotifications
|
||||||
|
|
||||||
|
/// What a notification response asks for, parsed out of the payload.
|
||||||
|
enum NotificationCommand: Equatable, Sendable {
|
||||||
|
case respond(hostId: String, threadId: String, requestId: String, decision: ApprovalDecision)
|
||||||
|
case reply(hostId: String, threadId: String, text: String)
|
||||||
|
case open(Route?)
|
||||||
|
case none
|
||||||
|
|
||||||
|
enum Action {
|
||||||
|
static let allow = "allow"
|
||||||
|
static let deny = "deny"
|
||||||
|
static let reply = "reply"
|
||||||
|
static let open = "open"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `userInfo` holds the custom keys `hostId`, `threadId` and, for approvals, `requestId`.
|
||||||
|
init(actionIdentifier: String, userInfo: [AnyHashable: Any], text: String?) {
|
||||||
|
let hostId = userInfo["hostId"] as? String
|
||||||
|
let threadId = userInfo["threadId"] as? String
|
||||||
|
let requestId = userInfo["requestId"] as? String
|
||||||
|
let thread: Route? = if let hostId, let threadId { .thread(hostId: hostId, threadId: threadId) } else { nil }
|
||||||
|
|
||||||
|
switch actionIdentifier {
|
||||||
|
case Action.allow, Action.deny:
|
||||||
|
guard let hostId, let threadId, let requestId else { self = .open(thread); return }
|
||||||
|
self = .respond(
|
||||||
|
hostId: hostId, threadId: threadId, requestId: requestId,
|
||||||
|
decision: actionIdentifier == Action.allow ? .accept : .decline)
|
||||||
|
case Action.reply:
|
||||||
|
let text = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard let hostId, let threadId, !text.isEmpty else { self = .open(thread); return }
|
||||||
|
self = .reply(hostId: hostId, threadId: threadId, text: text)
|
||||||
|
case UNNotificationDismissActionIdentifier:
|
||||||
|
self = .none
|
||||||
|
default:
|
||||||
|
// The Open action, or a tap on the notification itself.
|
||||||
|
self = .open(thread)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
|
||||||
|
static let shared = NotificationHandler()
|
||||||
|
|
||||||
|
func install() {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
center.delegate = self
|
||||||
|
center.setNotificationCategories(Self.categories)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var categories: Set<UNNotificationCategory> {
|
||||||
|
typealias Action = NotificationCommand.Action
|
||||||
|
let allow = UNNotificationAction(identifier: Action.allow, title: "Allow", options: [])
|
||||||
|
let deny = UNNotificationAction(identifier: Action.deny, title: "Deny", options: [.destructive])
|
||||||
|
let open = UNNotificationAction(identifier: Action.open, title: "Open", options: [.foreground])
|
||||||
|
let reply = UNTextInputNotificationAction(
|
||||||
|
identifier: Action.reply, title: "Reply", options: [],
|
||||||
|
textInputButtonTitle: "Send", textInputPlaceholder: "Reply")
|
||||||
|
|
||||||
|
func category(_ id: PushCategory, _ actions: [UNNotificationAction]) -> UNNotificationCategory {
|
||||||
|
UNNotificationCategory(identifier: id.rawValue, actions: actions, intentIdentifiers: [], options: [])
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
category(.approval, [allow, deny]),
|
||||||
|
category(.input, [open]),
|
||||||
|
category(.finished, [reply, open]),
|
||||||
|
category(.failed, [open]),
|
||||||
|
category(.host, []),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter, willPresent notification: UNNotification
|
||||||
|
) async -> UNNotificationPresentationOptions {
|
||||||
|
await AppModel.shared.notificationArrived()
|
||||||
|
return [.banner, .list, .sound]
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse
|
||||||
|
) async {
|
||||||
|
let command = NotificationCommand(
|
||||||
|
actionIdentifier: response.actionIdentifier,
|
||||||
|
userInfo: response.notification.request.content.userInfo,
|
||||||
|
text: (response as? UNTextInputNotificationResponse)?.userText)
|
||||||
|
await run(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func run(_ command: NotificationCommand) async {
|
||||||
|
let model = AppModel.shared
|
||||||
|
switch command {
|
||||||
|
case .none:
|
||||||
|
return
|
||||||
|
case let .open(route):
|
||||||
|
if let route { model.open(route) } else { model.path = [] }
|
||||||
|
await model.refreshFeed()
|
||||||
|
return
|
||||||
|
case let .respond(hostId, threadId, requestId, decision):
|
||||||
|
guard model.mode != .unpaired else { return }
|
||||||
|
await attempt("Couldn't answer") {
|
||||||
|
try await model.api.respond(
|
||||||
|
hostId: hostId, threadId: threadId,
|
||||||
|
request: .decision(requestId: requestId, decision: decision))
|
||||||
|
}
|
||||||
|
case let .reply(hostId, threadId, text):
|
||||||
|
guard model.mode != .unpaired else { return }
|
||||||
|
await attempt("Couldn't send your reply") {
|
||||||
|
try await model.api.sendMessage(hostId: hostId, threadId: threadId, text: text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await model.refreshFeed()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs a background action. If it fails, says so with a local notification, since nothing is
|
||||||
|
/// on screen.
|
||||||
|
private func attempt(_ title: String, _ action: () async throws -> Void) async {
|
||||||
|
do {
|
||||||
|
try await action()
|
||||||
|
} catch let error as GatewayError where error.isStale {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
guard let message = AppModel.shared.message(for: error) else { return }
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = title
|
||||||
|
content.body = message
|
||||||
|
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||||||
|
try? await UNUserNotificationCenter.current().add(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
// Mock B's attention feed: the count waiting on you, then cards for every host. Needs you first
|
||||||
|
// (with Allow and Deny on the card), then working (with what the agent is doing), then recent.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct FeedView: View {
|
||||||
|
@Environment(AppModel.self) private var model
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
@State private var visible = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
FeedHeader(feed: model.feed, error: model.feedError)
|
||||||
|
.feedRow()
|
||||||
|
|
||||||
|
if let feed = model.feed {
|
||||||
|
ForEach(feed.needsYou) { item in
|
||||||
|
NeedsYouCard(item: item).feedRow()
|
||||||
|
}
|
||||||
|
ForEach(feed.working) { item in
|
||||||
|
StatusCard(item: item).feedRow()
|
||||||
|
}
|
||||||
|
ForEach(feed.recent) { item in
|
||||||
|
StatusCard(item: item).feedRow()
|
||||||
|
}
|
||||||
|
if feed.needsYou.isEmpty && feed.working.isEmpty && feed.recent.isEmpty {
|
||||||
|
Text("No threads in the last day.")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
.feedRow()
|
||||||
|
}
|
||||||
|
} else if model.feedError != nil {
|
||||||
|
Button("Try again") { Task { await model.refreshFeed() } }
|
||||||
|
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white))
|
||||||
|
.feedRow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.refreshable { await model.refreshFeed() }
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
NavigationLink(value: Route.newThread) {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
.accessibilityLabel("New thread")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { visible = true }
|
||||||
|
.onDisappear { visible = false }
|
||||||
|
// Refresh on appear, when the app comes to the foreground, and every 10 s while on screen.
|
||||||
|
.task(id: visible && scenePhase == .active) {
|
||||||
|
guard visible && scenePhase == .active else { return }
|
||||||
|
while !Task.isCancelled {
|
||||||
|
await model.refreshFeed()
|
||||||
|
try? await Task.sleep(for: .seconds(10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension View {
|
||||||
|
func feedRow() -> some View {
|
||||||
|
listRowBackground(Color.clear)
|
||||||
|
.listRowInsets(EdgeInsets(top: 3, leading: 0, bottom: 3, trailing: 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The big count of threads waiting on you, plus offline hosts and refresh errors.
|
||||||
|
private struct FeedHeader: View {
|
||||||
|
var feed: Feed?
|
||||||
|
var error: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
if let feed {
|
||||||
|
let waiting = feed.needsYou.count
|
||||||
|
Text("\(waiting)")
|
||||||
|
.font(.system(size: 34, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(waiting > 0 ? Theme.you : Theme.done)
|
||||||
|
Text(subtitle(feed))
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
ForEach(feed.hosts.filter { $0.status != .online }) { host in
|
||||||
|
Label("\(host.label) \(host.status.problem ?? "")", systemImage: "wifi.slash")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Theme.done)
|
||||||
|
}
|
||||||
|
} else if error == nil {
|
||||||
|
Text("Loading")
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
}
|
||||||
|
if let error {
|
||||||
|
Text(error)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Theme.done)
|
||||||
|
.lineLimit(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func subtitle(_ feed: Feed) -> String {
|
||||||
|
let working = feed.working.count
|
||||||
|
let lead = feed.needsYou.isEmpty ? "nothing waiting" : "waiting on you"
|
||||||
|
return working > 0 ? "\(lead) · \(working) working" : lead
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An amber card for a thread waiting on an approval or an answer, with Allow and Deny inline.
|
||||||
|
private struct NeedsYouCard: View {
|
||||||
|
@Environment(AppModel.self) private var model
|
||||||
|
var item: FeedItem
|
||||||
|
@State private var sending = false
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Card(edge: Theme.you) {
|
||||||
|
Button { model.path.append(item.route) } label: {
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text("\(item.hostLabel) · \(item.projectTitle)")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundStyle(Theme.you)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(item.title)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.lineLimit(2)
|
||||||
|
if let request = item.request {
|
||||||
|
Text(request.summary)
|
||||||
|
.font(request.isQuestion ? .system(size: 12) : .system(size: 12, design: .monospaced))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
.lineLimit(3)
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
if let request = item.request, let quick = request.quickOptions {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Button(quick.allow.label) { respond(request, quick.allow.decision) }
|
||||||
|
.buttonStyle(ChoiceButtonStyle(fill: Theme.you, foreground: .black))
|
||||||
|
Button(quick.deny.label) { respond(request, quick.deny.decision) }
|
||||||
|
.buttonStyle(ChoiceButtonStyle())
|
||||||
|
}
|
||||||
|
.disabled(sending)
|
||||||
|
.opacity(sending ? 0.5 : 1)
|
||||||
|
.padding(.top, 7)
|
||||||
|
} else if item.request?.isQuestion == true {
|
||||||
|
Button("Answer") { model.path.append(item.route) }
|
||||||
|
.buttonStyle(ChoiceButtonStyle(fill: Theme.you, foreground: .black))
|
||||||
|
.padding(.top, 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error {
|
||||||
|
Text(error)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func respond(_ request: PendingRequest, _ decision: ApprovalDecision) {
|
||||||
|
sending = true
|
||||||
|
error = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await model.api.respond(
|
||||||
|
hostId: item.hostId, threadId: item.threadId,
|
||||||
|
request: .decision(requestId: request.id, decision: decision))
|
||||||
|
} catch let failure as GatewayError where failure.isStale {
|
||||||
|
// Someone answered it already; the refresh below drops the card.
|
||||||
|
} catch {
|
||||||
|
self.error = model.message(for: error)
|
||||||
|
}
|
||||||
|
await model.refreshFeed()
|
||||||
|
sending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A teal card for a working thread, or a grey one for a recent thread.
|
||||||
|
private struct StatusCard: View {
|
||||||
|
@Environment(AppModel.self) private var model
|
||||||
|
var item: FeedItem
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button { model.path.append(item.route) } label: {
|
||||||
|
Card(edge: item.phase.color) {
|
||||||
|
Text(meta)
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundStyle(item.phase == .failed ? Theme.secondaryText : item.phase.color)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(item.title)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(item.phase.isWorking ? Color.white : Theme.secondaryText)
|
||||||
|
.lineLimit(2)
|
||||||
|
.padding(.top, 1)
|
||||||
|
if item.phase.isWorking, let line = item.lastMessage {
|
||||||
|
Text(line)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
.lineLimit(2)
|
||||||
|
.padding(.top, 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var meta: String {
|
||||||
|
if item.phase.isWorking {
|
||||||
|
return "\(item.hostLabel) · \(item.projectTitle)"
|
||||||
|
}
|
||||||
|
let age = Format.age(of: item.updatedAt)
|
||||||
|
let state = item.phase == .failed ? "Failed" : age == "now" ? "just now" : "\(age) ago"
|
||||||
|
return "\(item.hostLabel) · \(state)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Feed") {
|
||||||
|
NavigationStack {
|
||||||
|
FeedView()
|
||||||
|
}
|
||||||
|
.environment(AppModel.preview())
|
||||||
|
.fontDesign(.rounded)
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
// Start a thread: pick where (host, then project), see the model it will use, dictate the task.
|
||||||
|
// Defaults to the last host and project a thread was started in.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ProjectRef: Hashable {
|
||||||
|
var hostId: String
|
||||||
|
var projectId: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NewThreadView: View {
|
||||||
|
@Environment(AppModel.self) private var model
|
||||||
|
@AppStorage("lastHostId") private var lastHostId = ""
|
||||||
|
@AppStorage("lastProjectId") private var lastProjectId = ""
|
||||||
|
|
||||||
|
@State private var projects: ProjectsResponse?
|
||||||
|
@State private var selection: ProjectRef?
|
||||||
|
@State private var picking = false
|
||||||
|
@State private var sending = false
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
if let projects {
|
||||||
|
let chosen = resolve(selection, in: projects)
|
||||||
|
Button { picking = true } label: {
|
||||||
|
Card(edge: Theme.done) {
|
||||||
|
Text("Where")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
Text(chosen.map { "\($0.host.label) › \($0.project.title)" } ?? "Choose a project")
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
if let chosen {
|
||||||
|
Card(edge: Theme.done) {
|
||||||
|
Text("Agent")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
Text(chosen.project.model.map(Format.modelName) ?? "")
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
}
|
||||||
|
|
||||||
|
TextFieldLink(prompt: Text("What should it do?")) {
|
||||||
|
Label(sending ? "Starting" : "Say the task", systemImage: "mic.fill")
|
||||||
|
} onSubmit: { text in
|
||||||
|
start(text, in: chosen)
|
||||||
|
}
|
||||||
|
.buttonStyle(PillButtonStyle(fill: Theme.you))
|
||||||
|
.disabled(sending || chosen.host.status != .online)
|
||||||
|
.padding(.top, 4)
|
||||||
|
|
||||||
|
if chosen.host.status != .online {
|
||||||
|
Text("\(chosen.host.label) is \(chosen.host.status.problem ?? "offline").")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if error == nil {
|
||||||
|
Text("Loading")
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
}
|
||||||
|
if let error {
|
||||||
|
Text(error)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
.navigationTitle("New thread")
|
||||||
|
.toolbarForegroundStyle(Theme.you, for: .navigationBar)
|
||||||
|
.sheet(isPresented: $picking) {
|
||||||
|
if let projects {
|
||||||
|
ProjectPicker(projects: projects) { ref in
|
||||||
|
selection = ref
|
||||||
|
picking = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
do {
|
||||||
|
let response = try await model.api.projects()
|
||||||
|
projects = response
|
||||||
|
if selection == nil {
|
||||||
|
selection = Self.defaultSelection(
|
||||||
|
in: response, last: ProjectRef(hostId: lastHostId, projectId: lastProjectId))
|
||||||
|
}
|
||||||
|
error = nil
|
||||||
|
} catch {
|
||||||
|
self.error = model.message(for: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolve(_ ref: ProjectRef?, in projects: ProjectsResponse)
|
||||||
|
-> (host: ProjectsResponse.Host, project: ProjectsResponse.Project)?
|
||||||
|
{
|
||||||
|
guard let ref,
|
||||||
|
let host = projects.hosts.first(where: { $0.id == ref.hostId }),
|
||||||
|
let project = host.launchable.first(where: { $0.id == ref.projectId })
|
||||||
|
else { return nil }
|
||||||
|
return (host, project)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last used project when it can still start threads, else the first project on the
|
||||||
|
/// first online host.
|
||||||
|
nonisolated static func defaultSelection(in projects: ProjectsResponse, last: ProjectRef) -> ProjectRef? {
|
||||||
|
if let host = projects.hosts.first(where: { $0.id == last.hostId }),
|
||||||
|
host.launchable.contains(where: { $0.id == last.projectId })
|
||||||
|
{
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
let hosts = projects.hosts.filter { $0.status == .online } + projects.hosts.filter { $0.status != .online }
|
||||||
|
for host in hosts {
|
||||||
|
if let project = host.launchable.first {
|
||||||
|
return ProjectRef(hostId: host.id, projectId: project.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func start(_ text: String, in chosen: (host: ProjectsResponse.Host, project: ProjectsResponse.Project)) {
|
||||||
|
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !text.isEmpty else { return }
|
||||||
|
sending = true
|
||||||
|
error = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let created = try await model.api.newThread(
|
||||||
|
NewThreadRequest(hostId: chosen.host.id, projectId: chosen.project.id, text: text))
|
||||||
|
lastHostId = chosen.host.id
|
||||||
|
lastProjectId = chosen.project.id
|
||||||
|
await model.refreshFeed()
|
||||||
|
// Replace this screen with the new thread, so Back returns to the feed.
|
||||||
|
let route = Route.thread(hostId: created.hostId, threadId: created.threadId)
|
||||||
|
if model.path.last == .newThread {
|
||||||
|
model.path[model.path.count - 1] = route
|
||||||
|
} else {
|
||||||
|
model.path.append(route)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.error = model.message(for: error)
|
||||||
|
}
|
||||||
|
sending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two steps: the environment (host), then one of its projects.
|
||||||
|
private struct ProjectPicker: View {
|
||||||
|
var projects: ProjectsResponse
|
||||||
|
var pick: (ProjectRef) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List(projects.hosts) { host in
|
||||||
|
NavigationLink {
|
||||||
|
List(host.launchable) { project in
|
||||||
|
Button {
|
||||||
|
pick(ProjectRef(hostId: host.id, projectId: project.id))
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(project.title)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
Text(project.model.map(Format.modelName) ?? "")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(host.label)
|
||||||
|
.overlay {
|
||||||
|
if host.launchable.isEmpty {
|
||||||
|
Text("No projects with a default model.")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(host.label)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
Text(host.status.problem ?? "\(host.launchable.count) projects")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(host.status == .online ? Theme.secondaryText : Theme.done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Where")
|
||||||
|
}
|
||||||
|
.fontDesign(.rounded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("New thread") {
|
||||||
|
NavigationStack {
|
||||||
|
NewThreadView()
|
||||||
|
}
|
||||||
|
.environment(AppModel.preview())
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Pairing: the gateway URL and the 6 digit code that `wristcode pair` prints on the gateway.
|
||||||
|
// Shown whenever there is no device token, or the gateway answers 401.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PairView: View {
|
||||||
|
@Environment(AppModel.self) private var model
|
||||||
|
@State private var url = DeviceStore.lastGatewayURL
|
||||||
|
@State private var code = ""
|
||||||
|
@State private var sending = false
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
private var digits: String { code.filter(\.isNumber) }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("WristCode")
|
||||||
|
.font(.system(size: 22, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(Theme.you)
|
||||||
|
Text("Run wristcode pair on the gateway, then enter the code.")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
|
||||||
|
field("Gateway") {
|
||||||
|
TextField("Gateway URL", text: $url)
|
||||||
|
.textContentType(.URL)
|
||||||
|
}
|
||||||
|
field("Code") {
|
||||||
|
TextField("6 digits", text: $code)
|
||||||
|
.textContentType(.oneTimeCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(sending ? "Pairing" : "Pair") { pair() }
|
||||||
|
.buttonStyle(PillButtonStyle(fill: Theme.you))
|
||||||
|
.disabled(sending || digits.count != 6)
|
||||||
|
.opacity(digits.count == 6 ? 1 : 0.5)
|
||||||
|
.padding(.top, 4)
|
||||||
|
|
||||||
|
if let error {
|
||||||
|
Text(error)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Try the demo") { model.startDemo() }
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(Theme.work)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.top, 6)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func field(_ label: String, @ViewBuilder input: () -> some View) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
input()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pair() {
|
||||||
|
guard let gatewayURL = DeviceStore.normalizeGatewayURL(url) else {
|
||||||
|
error = "That gateway URL doesn't look right."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sending = true
|
||||||
|
error = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await model.pair(gatewayURL: gatewayURL, code: digits)
|
||||||
|
} catch let failure as GatewayError where failure == .unauthorized {
|
||||||
|
error = "That code is wrong or expired."
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
sending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Pair") {
|
||||||
|
PairView()
|
||||||
|
.environment(AppModel(mode: .unpaired))
|
||||||
|
.fontDesign(.rounded)
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// Mock B colors and the shared card. Amber means it needs you, teal means working, grey means done.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum Theme {
|
||||||
|
static let you = Color(red: 1.0, green: 0.690, blue: 0.180) // #ffb02e
|
||||||
|
static let work = Color(red: 0.310, green: 0.820, blue: 0.773) // #4fd1c5
|
||||||
|
static let done = Color(red: 0.420, green: 0.420, blue: 0.439) // #6b6b70
|
||||||
|
static let card = Color(red: 0.090, green: 0.090, blue: 0.102) // #17171a
|
||||||
|
static let control = Color(red: 0.173, green: 0.173, blue: 0.180) // #2c2c2e
|
||||||
|
static let secondaryText = Color(red: 0.682, green: 0.682, blue: 0.698) // #aeaeb2
|
||||||
|
static let bodyText = Color(red: 0.898, green: 0.898, blue: 0.918) // #e5e5ea
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Phase {
|
||||||
|
var color: Color {
|
||||||
|
if needsYou { return Theme.you }
|
||||||
|
if isWorking { return Theme.work }
|
||||||
|
return Theme.done
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A dark rounded card with a 4 pt colored left edge.
|
||||||
|
struct Card<Content: View>: View {
|
||||||
|
var edge: Color
|
||||||
|
@ViewBuilder var content: Content
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.vertical, 9)
|
||||||
|
.padding(.leading, 15)
|
||||||
|
.padding(.trailing, 11)
|
||||||
|
.background(alignment: .leading) {
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Theme.card
|
||||||
|
edge.frame(width: 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A full-width pill button, like mock B's Reply and Say the task.
|
||||||
|
struct PillButtonStyle: ButtonStyle {
|
||||||
|
var fill: Color
|
||||||
|
var foreground: Color = .black
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.font(.system(size: 16, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(foreground)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(fill.opacity(configuration.isPressed ? 0.7 : 1), in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A smaller button for choices on a card, such as Allow and Deny.
|
||||||
|
struct ChoiceButtonStyle: ButtonStyle {
|
||||||
|
var fill: Color = Theme.control
|
||||||
|
var foreground: Color = .white
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(foreground)
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.background(fill.opacity(configuration.isPressed ? 0.7 : 1), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Format {
|
||||||
|
/// A short age for a card, such as "now", "4 min", "2 h" or "3 d".
|
||||||
|
static func age(of date: Date, now: Date = .now) -> String {
|
||||||
|
let minutes = Int(now.timeIntervalSince(date) / 60)
|
||||||
|
if minutes < 1 { return "now" }
|
||||||
|
if minutes < 60 { return "\(minutes) min" }
|
||||||
|
let hours = minutes / 60
|
||||||
|
if hours < 24 { return "\(hours) h" }
|
||||||
|
return "\(hours / 24) d"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A model id made readable: `claude-opus-5-5` becomes "Claude Opus 5.5".
|
||||||
|
static func modelName(_ id: String) -> String {
|
||||||
|
var words: [String] = []
|
||||||
|
var number: [String] = []
|
||||||
|
func flush() {
|
||||||
|
if !number.isEmpty { words.append(number.joined(separator: ".")) }
|
||||||
|
number = []
|
||||||
|
}
|
||||||
|
for part in id.split(whereSeparator: { $0 == "-" || $0 == "_" }).map(String.init) {
|
||||||
|
if part.allSatisfy(\.isNumber) {
|
||||||
|
number.append(part)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
switch part.lowercased() {
|
||||||
|
case "gpt": words.append("GPT")
|
||||||
|
default: words.append(part.prefix(1).uppercased() + part.dropFirst())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return words.joined(separator: " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
// One thread: the agent's latest message large, older messages one scroll up, the pending approval
|
||||||
|
// or question with its options, Reply by dictation or Scribble, and Stop while a run is active.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ThreadView: View {
|
||||||
|
@Environment(AppModel.self) private var model
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
let hostId: String
|
||||||
|
let threadId: String
|
||||||
|
|
||||||
|
@State private var detail: ThreadDetail?
|
||||||
|
@State private var error: String?
|
||||||
|
@State private var sending = false
|
||||||
|
@State private var visible = false
|
||||||
|
@State private var answers: [String: String] = [:]
|
||||||
|
@State private var scrolledToLatest = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
if let detail {
|
||||||
|
content(detail)
|
||||||
|
} else if error == nil {
|
||||||
|
Text("Loading")
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
}
|
||||||
|
if let error {
|
||||||
|
Text(error)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
.onChange(of: detail?.messages.last?.id) {
|
||||||
|
guard !scrolledToLatest, detail != nil else { return }
|
||||||
|
scrolledToLatest = true
|
||||||
|
proxy.scrollTo("latest", anchor: .top)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if detail != nil, !scrolledToLatest {
|
||||||
|
scrolledToLatest = true
|
||||||
|
proxy.scrollTo("latest", anchor: .top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(detail?.item.title ?? "Thread")
|
||||||
|
.onAppear { visible = true }
|
||||||
|
.onDisappear { visible = false }
|
||||||
|
.task(id: visible && scenePhase == .active) {
|
||||||
|
guard visible && scenePhase == .active else { return }
|
||||||
|
while !Task.isCancelled {
|
||||||
|
await load()
|
||||||
|
try? await Task.sleep(for: .seconds(10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func content(_ detail: ThreadDetail) -> some View {
|
||||||
|
let split = MessageSplit(detail.messages)
|
||||||
|
let item = detail.item
|
||||||
|
|
||||||
|
ForEach(split.earlier) { message in
|
||||||
|
MessageRow(message: message)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(split.latest?.text ?? item.lastMessage ?? "No reply yet.")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundStyle(Theme.bodyText)
|
||||||
|
.lineSpacing(1)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.id("latest")
|
||||||
|
.padding(.top, split.earlier.isEmpty ? 0 : 6)
|
||||||
|
|
||||||
|
ForEach(split.after) { message in
|
||||||
|
MessageRow(message: message)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(statusLine(item))
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundStyle(item.phase.color)
|
||||||
|
|
||||||
|
if let request = item.request {
|
||||||
|
if request.isQuestion {
|
||||||
|
questions(request)
|
||||||
|
} else {
|
||||||
|
approval(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.phase == .completed && item.request == nil {
|
||||||
|
QuickReplies { send($0) }
|
||||||
|
.disabled(sending)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextFieldLink(prompt: Text("Reply")) {
|
||||||
|
Label("Reply", systemImage: "mic.fill")
|
||||||
|
} onSubmit: { text in
|
||||||
|
send(text)
|
||||||
|
}
|
||||||
|
.buttonStyle(PillButtonStyle(fill: Theme.work))
|
||||||
|
.disabled(sending)
|
||||||
|
|
||||||
|
if detail.canStop {
|
||||||
|
Button(role: .destructive) { stop() } label: {
|
||||||
|
Label("Stop", systemImage: "stop.fill")
|
||||||
|
}
|
||||||
|
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .red))
|
||||||
|
.disabled(sending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func approval(_ request: PendingRequest) -> some View {
|
||||||
|
Card(edge: Theme.you) {
|
||||||
|
if let prompt = request.prompt {
|
||||||
|
Text(prompt)
|
||||||
|
.font(.system(size: 13, design: .monospaced))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
} else {
|
||||||
|
Text(request.summary)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
}
|
||||||
|
if let reason = request.reason {
|
||||||
|
Text(reason)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
.padding(.top, 3)
|
||||||
|
}
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
ForEach(Array(request.options.enumerated()), id: \.offset) { index, option in
|
||||||
|
Button(option.label) { respond(.decision(requestId: request.id, decision: option.decision)) }
|
||||||
|
.buttonStyle(ChoiceButtonStyle(
|
||||||
|
fill: index == 0 ? Theme.you : Theme.control,
|
||||||
|
foreground: index == 0 ? .black : .white))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
.disabled(sending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func questions(_ request: PendingRequest) -> some View {
|
||||||
|
ForEach(request.questions) { question in
|
||||||
|
Card(edge: Theme.you) {
|
||||||
|
Text(question.header)
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundStyle(Theme.you)
|
||||||
|
Text(question.question)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.padding(.top, 1)
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
ForEach(question.options, id: \.value) { choice in
|
||||||
|
let chosen = answers[question.id] == choice.value
|
||||||
|
Button(choice.label) { answer(request, question.id, choice.value) }
|
||||||
|
.buttonStyle(ChoiceButtonStyle(
|
||||||
|
fill: chosen ? Theme.you : Theme.control,
|
||||||
|
foreground: chosen ? .black : .white))
|
||||||
|
}
|
||||||
|
if question.allowCustomAnswer {
|
||||||
|
TextFieldLink(prompt: Text(question.question)) {
|
||||||
|
Text("Other…")
|
||||||
|
} onSubmit: { text in
|
||||||
|
answer(request, question.id, text)
|
||||||
|
}
|
||||||
|
.buttonStyle(ChoiceButtonStyle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
.disabled(sending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusLine(_ item: FeedItem) -> String {
|
||||||
|
let place = "\(item.hostLabel) · \(item.projectTitle)"
|
||||||
|
return switch item.phase {
|
||||||
|
case .waitingForApproval: "Needs approval · \(place)"
|
||||||
|
case .waitingForInput: "Has a question · \(place)"
|
||||||
|
case .starting, .running: "Working · \(place)"
|
||||||
|
case .failed: "Failed · \(place)"
|
||||||
|
default: place
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
do {
|
||||||
|
detail = try await model.api.thread(hostId: hostId, threadId: threadId)
|
||||||
|
error = nil
|
||||||
|
} catch {
|
||||||
|
self.error = model.message(for: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func perform(_ action: @escaping @MainActor () async throws -> Void) {
|
||||||
|
sending = true
|
||||||
|
error = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await action()
|
||||||
|
} catch let failure as GatewayError where failure.isStale {
|
||||||
|
self.error = "Already answered."
|
||||||
|
} catch {
|
||||||
|
self.error = model.message(for: error)
|
||||||
|
}
|
||||||
|
await load()
|
||||||
|
await model.refreshFeed()
|
||||||
|
sending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func send(_ text: String) {
|
||||||
|
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !text.isEmpty else { return }
|
||||||
|
perform { try await model.api.sendMessage(hostId: hostId, threadId: threadId, text: text) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func respond(_ request: RespondRequest) {
|
||||||
|
perform { try await model.api.respond(hostId: hostId, threadId: threadId, request: request) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records one answer. Once every question has one, sends them together.
|
||||||
|
private func answer(_ request: PendingRequest, _ questionId: String, _ value: String) {
|
||||||
|
answers[questionId] = value
|
||||||
|
let all = request.questions.allSatisfy { answers[$0.id] != nil }
|
||||||
|
guard all else { return }
|
||||||
|
let payload = answers.filter { key, _ in request.questions.contains { $0.id == key } }
|
||||||
|
answers = [:]
|
||||||
|
respond(.answers(requestId: request.id, answers: payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stop() {
|
||||||
|
perform { try await model.api.stop(hostId: hostId, threadId: threadId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits a thread's messages around the agent's latest message, which the screen shows large.
|
||||||
|
struct MessageSplit {
|
||||||
|
var earlier: [Message]
|
||||||
|
var latest: Message?
|
||||||
|
var after: [Message]
|
||||||
|
|
||||||
|
init(_ messages: [Message]) {
|
||||||
|
guard let index = messages.lastIndex(where: { $0.role == .assistant }) else {
|
||||||
|
earlier = messages
|
||||||
|
latest = nil
|
||||||
|
after = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
earlier = Array(messages[..<index])
|
||||||
|
latest = messages[index]
|
||||||
|
after = Array(messages[(index + 1)...])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MessageRow: View {
|
||||||
|
var message: Message
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let mine = message.role == .user
|
||||||
|
Text(message.text)
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(mine ? Color.white : Theme.secondaryText)
|
||||||
|
.padding(.horizontal, mine ? 9 : 0)
|
||||||
|
.padding(.vertical, mine ? 6 : 0)
|
||||||
|
.background(mine ? Theme.card : Color.clear, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
.frame(maxWidth: .infinity, alignment: mine ? .trailing : .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-tap replies for a finished turn, like mock B's chips.
|
||||||
|
private struct QuickReplies: View {
|
||||||
|
var send: (String) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ForEach(["Yes", "Continue", "Commit it"], id: \.self) { reply in
|
||||||
|
Button(reply) { send(reply) }
|
||||||
|
.buttonStyle(ChoiceButtonStyle(fill: Theme.card))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Finished") {
|
||||||
|
NavigationStack {
|
||||||
|
ThreadView(hostId: "littleman", threadId: "t-next")
|
||||||
|
}
|
||||||
|
.environment(AppModel.preview())
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Approval") {
|
||||||
|
NavigationStack {
|
||||||
|
ThreadView(hostId: "mac-mini", threadId: "t-ocr")
|
||||||
|
}
|
||||||
|
.environment(AppModel.preview())
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Question") {
|
||||||
|
NavigationStack {
|
||||||
|
ThreadView(hostId: "server-gib", threadId: "t-db")
|
||||||
|
}
|
||||||
|
.environment(AppModel.preview())
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// App entry. Notification setup and APNs registration go through the WatchKit app delegate.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import WatchKit
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct WristCodeApp: App {
|
||||||
|
@WKApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
||||||
|
@State private var model = AppModel.shared
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
RootView()
|
||||||
|
.environment(model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RootView: View {
|
||||||
|
@Environment(AppModel.self) private var model
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
@Bindable var model = model
|
||||||
|
Group {
|
||||||
|
if model.mode == .unpaired {
|
||||||
|
PairView()
|
||||||
|
} else {
|
||||||
|
NavigationStack(path: $model.path) {
|
||||||
|
FeedView()
|
||||||
|
.navigationDestination(for: Route.self) { route in
|
||||||
|
switch route {
|
||||||
|
case let .thread(hostId, threadId):
|
||||||
|
ThreadView(hostId: hostId, threadId: threadId)
|
||||||
|
case .newThread:
|
||||||
|
NewThreadView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.fontDesign(.rounded)
|
||||||
|
.tint(Theme.work)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class AppDelegate: NSObject, WKApplicationDelegate {
|
||||||
|
func applicationDidFinishLaunching() {
|
||||||
|
NotificationHandler.shared.install()
|
||||||
|
Task { await AppModel.shared.enableNotifications() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func didRegisterForRemoteNotifications(withDeviceToken deviceToken: Data) {
|
||||||
|
AppModel.shared.didRegister(apnsToken: deviceToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func didFailToRegisterForRemoteNotificationsWithError(_ error: any Error) {
|
||||||
|
print("APNs registration failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
// Decodes the docs/api.md examples into the Codable models, and checks the small bits of logic
|
||||||
|
// the screens rely on.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import WristCode
|
||||||
|
|
||||||
|
private func decode<T: Decodable>(_ type: T.Type, _ json: String) throws -> T {
|
||||||
|
try JSON.decoder().decode(type, from: Data(json.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func encodeObject(_ value: some Encodable) throws -> [String: Any] {
|
||||||
|
let data = try JSON.encoder().encode(value)
|
||||||
|
return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||||
|
}
|
||||||
|
|
||||||
|
private let feedItemJSON = """
|
||||||
|
{
|
||||||
|
"hostId": "...", "hostLabel": "Mac mini",
|
||||||
|
"threadId": "...", "projectId": "...", "projectTitle": "bill-tracker",
|
||||||
|
"title": "Receipt OCR",
|
||||||
|
"phase": "waiting_for_approval",
|
||||||
|
"updatedAt": "2026-11-01T09:41:00.000Z",
|
||||||
|
"lastMessage": "Parser and tests are written. I want to run the suite.",
|
||||||
|
"request": {
|
||||||
|
"id": "...",
|
||||||
|
"kind": "command",
|
||||||
|
"prompt": "bun test --filter ocr",
|
||||||
|
"reason": null,
|
||||||
|
"options": [{ "decision": "accept", "label": "Allow" }, { "decision": "decline", "label": "Deny" }],
|
||||||
|
"questions": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
@Suite struct DecodingTests {
|
||||||
|
@Test func feedFromDocs() throws {
|
||||||
|
let feed = try decode(Feed.self, """
|
||||||
|
{
|
||||||
|
"needsYou": [\(feedItemJSON)],
|
||||||
|
"working": [],
|
||||||
|
"recent": [],
|
||||||
|
"hosts": [{ "id": "...", "label": "Mac mini", "status": "online", "error": null }]
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
#expect(feed.needsYou.count == 1)
|
||||||
|
#expect(feed.hosts.first?.status == .online)
|
||||||
|
#expect(feed.hosts.first?.error == nil)
|
||||||
|
|
||||||
|
let item = try #require(feed.needsYou.first)
|
||||||
|
#expect(item.phase == .waitingForApproval)
|
||||||
|
#expect(item.phase.needsYou)
|
||||||
|
#expect(item.updatedAt == Date(timeIntervalSince1970: 1_793_526_060))
|
||||||
|
let request = try #require(item.request)
|
||||||
|
#expect(request.prompt == "bun test --filter ocr")
|
||||||
|
#expect(request.reason == nil)
|
||||||
|
#expect(request.quickOptions?.allow.label == "Allow")
|
||||||
|
#expect(request.quickOptions?.deny.decision == .decline)
|
||||||
|
#expect(request.summary == "bun test --filter ocr")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func questionRequest() throws {
|
||||||
|
let request = try decode(PendingRequest.self, """
|
||||||
|
{ "id": "r1", "kind": "user_input", "prompt": null, "reason": null, "options": [],
|
||||||
|
"questions": [{ "id": "q1", "header": "Storage", "question": "Hash the codes?",
|
||||||
|
"options": [{ "label": "Yes", "value": "yes" }], "allowCustomAnswer": true }] }
|
||||||
|
""")
|
||||||
|
#expect(request.isQuestion)
|
||||||
|
#expect(request.quickOptions == nil)
|
||||||
|
#expect(request.summary == "Hash the codes?")
|
||||||
|
#expect(request.questions.first?.allowCustomAnswer == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unknownEnumValuesStillDecode() throws {
|
||||||
|
let item = try decode(FeedItem.self, feedItemJSON
|
||||||
|
.replacingOccurrences(of: "waiting_for_approval", with: "hibernating")
|
||||||
|
.replacingOccurrences(of: "\"decline\"", with: "\"rejectForever\""))
|
||||||
|
#expect(item.phase.rawValue == "hibernating")
|
||||||
|
#expect(!item.phase.needsYou && !item.phase.isWorking)
|
||||||
|
#expect(item.request?.options.last?.decision.rawValue == "rejectForever")
|
||||||
|
|
||||||
|
let host = try decode(HostSummary.self, #"{ "id": "h", "label": "L", "status": "sleeping", "error": "zzz" }"#)
|
||||||
|
#expect(host.status.problem == "offline")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func threadFromDocs() throws {
|
||||||
|
let detail = try decode(ThreadDetail.self, """
|
||||||
|
{ "item": \(feedItemJSON),
|
||||||
|
"messages": [{ "id": "...", "role": "assistant", "text": "...", "createdAt": "2026-11-01T09:40:00Z" }],
|
||||||
|
"canStop": true }
|
||||||
|
""")
|
||||||
|
#expect(detail.canStop)
|
||||||
|
#expect(detail.messages.first?.role == .assistant)
|
||||||
|
#expect(detail.messages.first?.createdAt == Date(timeIntervalSince1970: 1_793_526_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func projectsFromDocs() throws {
|
||||||
|
let response = try decode(ProjectsResponse.self, """
|
||||||
|
{ "hosts": [{ "id": "...", "label": "Mac mini", "status": "online", "error": null,
|
||||||
|
"projects": [{ "id": "a", "title": "wristcode", "model": "claude-opus-5-5" },
|
||||||
|
{ "id": "b", "title": "scratch", "model": null }] }] }
|
||||||
|
""")
|
||||||
|
let host = try #require(response.hosts.first)
|
||||||
|
#expect(host.projects.count == 2)
|
||||||
|
#expect(host.launchable.map(\.id) == ["a"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func smallResponses() throws {
|
||||||
|
#expect(try decode(PairResponse.self, #"{ "deviceId": "9b0c", "deviceToken": "wc_x" }"#).deviceToken == "wc_x")
|
||||||
|
#expect(try decode(NewThreadResponse.self, #"{ "hostId": "h", "threadId": "t" }"#).threadId == "t")
|
||||||
|
#expect(try decode(ErrorResponse.self, #"{ "error": "That host is offline" }"#).error == "That host is offline")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func badDateFails() {
|
||||||
|
#expect(throws: DecodingError.self) {
|
||||||
|
try decode(Message.self, #"{ "id": "m", "role": "user", "text": "x", "createdAt": "yesterday" }"#)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite struct EncodingTests {
|
||||||
|
@Test func respondWithDecision() throws {
|
||||||
|
let object = try encodeObject(RespondRequest.decision(requestId: "r1", decision: .accept))
|
||||||
|
#expect(object["requestId"] as? String == "r1")
|
||||||
|
#expect(object["decision"] as? String == "accept")
|
||||||
|
#expect(object["answers"] == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func respondWithAnswers() throws {
|
||||||
|
let object = try encodeObject(RespondRequest.answers(requestId: "r1", answers: ["q1": "yes"]))
|
||||||
|
#expect(object["answers"] as? [String: String] == ["q1": "yes"])
|
||||||
|
#expect(object["decision"] == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func pushRegistration() throws {
|
||||||
|
let object = try encodeObject(PushRegistration(apnsToken: "ab", apnsEnvironment: .sandbox))
|
||||||
|
#expect(object["apnsEnvironment"] as? String == "sandbox")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite struct LogicTests {
|
||||||
|
@Test func gatewayURLNormalizing() {
|
||||||
|
#expect(DeviceStore.normalizeGatewayURL(" wristcode.gbrown.org/ ")?.absoluteString == "https://wristcode.gbrown.org")
|
||||||
|
#expect(DeviceStore.normalizeGatewayURL("http://10.0.0.5:8080")?.absoluteString == "http://10.0.0.5:8080")
|
||||||
|
#expect(DeviceStore.normalizeGatewayURL("") == nil)
|
||||||
|
#expect(DeviceStore.normalizeGatewayURL("ftp://x.org") == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func modelNames() {
|
||||||
|
#expect(Format.modelName("claude-opus-5-5") == "Claude Opus 5.5")
|
||||||
|
#expect(Format.modelName("claude-haiku-5-5") == "Claude Haiku 5.5")
|
||||||
|
#expect(Format.modelName("gpt-5-codex") == "GPT 5 Codex")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func ages() {
|
||||||
|
let now = Date(timeIntervalSince1970: 100_000)
|
||||||
|
#expect(Format.age(of: now, now: now) == "now")
|
||||||
|
#expect(Format.age(of: now.addingTimeInterval(-4 * 60), now: now) == "4 min")
|
||||||
|
#expect(Format.age(of: now.addingTimeInterval(-62 * 60), now: now) == "1 h")
|
||||||
|
#expect(Format.age(of: now.addingTimeInterval(-50 * 3600), now: now) == "2 d")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func messageSplitShowsLatestAgentMessage() {
|
||||||
|
func m(_ id: String, _ role: Message.Role) -> Message { Message(id: id, role: role, text: id, createdAt: .now) }
|
||||||
|
let split = MessageSplit([m("1", .user), m("2", .assistant), m("3", .assistant), m("4", .user)])
|
||||||
|
#expect(split.earlier.map(\.id) == ["1", "2"])
|
||||||
|
#expect(split.latest?.id == "3")
|
||||||
|
#expect(split.after.map(\.id) == ["4"])
|
||||||
|
#expect(MessageSplit([m("1", .user)]).latest == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func defaultProjectPrefersLastUsed() {
|
||||||
|
let projects = ProjectsResponse(hosts: [
|
||||||
|
.init(id: "off", label: "Off", status: .offline, error: nil, projects: [.init(id: "x", title: "x", model: "m")]),
|
||||||
|
.init(id: "on", label: "On", status: .online, error: nil, projects: [
|
||||||
|
.init(id: "none", title: "none", model: nil),
|
||||||
|
.init(id: "a", title: "a", model: "m"),
|
||||||
|
.init(id: "b", title: "b", model: "m"),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
let last = ProjectRef(hostId: "on", projectId: "b")
|
||||||
|
#expect(NewThreadView.defaultSelection(in: projects, last: last) == last)
|
||||||
|
// A forgotten or model-less project falls back to the first online host's first usable project.
|
||||||
|
#expect(NewThreadView.defaultSelection(in: projects, last: ProjectRef(hostId: "on", projectId: "none"))
|
||||||
|
== ProjectRef(hostId: "on", projectId: "a"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func notificationCommands() {
|
||||||
|
let info: [AnyHashable: Any] = ["hostId": "h", "threadId": "t", "requestId": "r"]
|
||||||
|
#expect(NotificationCommand(actionIdentifier: "allow", userInfo: info, text: nil)
|
||||||
|
== .respond(hostId: "h", threadId: "t", requestId: "r", decision: .accept))
|
||||||
|
#expect(NotificationCommand(actionIdentifier: "deny", userInfo: info, text: nil)
|
||||||
|
== .respond(hostId: "h", threadId: "t", requestId: "r", decision: .decline))
|
||||||
|
#expect(NotificationCommand(actionIdentifier: "reply", userInfo: info, text: " Commit it ")
|
||||||
|
== .reply(hostId: "h", threadId: "t", text: "Commit it"))
|
||||||
|
#expect(NotificationCommand(actionIdentifier: "com.apple.UNNotificationDefaultActionIdentifier", userInfo: info, text: nil)
|
||||||
|
== .open(.thread(hostId: "h", threadId: "t")))
|
||||||
|
// A host notification has no thread, so tapping it opens the feed.
|
||||||
|
#expect(NotificationCommand(actionIdentifier: "com.apple.UNNotificationDefaultActionIdentifier", userInfo: [:], text: nil)
|
||||||
|
== .open(nil))
|
||||||
|
// Allow without a requestId cannot answer, so it opens the thread instead.
|
||||||
|
#expect(NotificationCommand(actionIdentifier: "allow", userInfo: ["hostId": "h", "threadId": "t"], text: nil)
|
||||||
|
== .open(.thread(hostId: "h", threadId: "t")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suite struct DemoGatewayTests {
|
||||||
|
@Test func allowMovesThreadToWorking() async throws {
|
||||||
|
let gateway = DemoGateway()
|
||||||
|
let before = try await gateway.feed()
|
||||||
|
let item = try #require(before.needsYou.first { $0.request?.quickOptions != nil })
|
||||||
|
try await gateway.respond(
|
||||||
|
hostId: item.hostId, threadId: item.threadId,
|
||||||
|
request: .decision(requestId: item.request!.id, decision: .accept))
|
||||||
|
let after = try await gateway.feed()
|
||||||
|
#expect(!after.needsYou.contains { $0.id == item.id })
|
||||||
|
#expect(after.working.contains { $0.id == item.id })
|
||||||
|
await #expect(throws: GatewayError.http(status: 409, message: "Already answered.")) {
|
||||||
|
try await gateway.respond(
|
||||||
|
hostId: item.hostId, threadId: item.threadId,
|
||||||
|
request: .decision(requestId: item.request!.id, decision: .accept))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
name: WristCode
|
||||||
|
options:
|
||||||
|
bundleIdPrefix: org.gbrown
|
||||||
|
createIntermediateGroups: true
|
||||||
|
deploymentTarget:
|
||||||
|
watchOS: "27.0"
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
DEVELOPMENT_TEAM: GQJNM5YK2M
|
||||||
|
CODE_SIGN_STYLE: Automatic
|
||||||
|
SWIFT_VERSION: "6.0"
|
||||||
|
SWIFT_STRICT_CONCURRENCY: complete
|
||||||
|
MARKETING_VERSION: "0.1.0"
|
||||||
|
CURRENT_PROJECT_VERSION: "1"
|
||||||
|
targets:
|
||||||
|
WristCode:
|
||||||
|
type: application
|
||||||
|
platform: watchOS
|
||||||
|
sources:
|
||||||
|
- WristCode
|
||||||
|
info:
|
||||||
|
path: WristCode/Info.plist
|
||||||
|
properties:
|
||||||
|
CFBundleDisplayName: WristCode
|
||||||
|
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||||
|
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||||
|
WKApplication: true
|
||||||
|
# Watch only, with no iPhone companion. watchOS refuses to install an app that sets both
|
||||||
|
# this and WKRunsIndependentlyOfCompanionApp, and requires WKCompanionAppBundleIdentifier
|
||||||
|
# when WKRunsIndependentlyOfCompanionApp is set without WKWatchOnly.
|
||||||
|
WKWatchOnly: true
|
||||||
|
entitlements:
|
||||||
|
path: WristCode/WristCode.entitlements
|
||||||
|
properties:
|
||||||
|
aps-environment: development
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: org.gbrown.wristcode
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
|
WristCodeTests:
|
||||||
|
type: bundle.unit-test
|
||||||
|
platform: watchOS
|
||||||
|
sources:
|
||||||
|
- WristCodeTests
|
||||||
|
dependencies:
|
||||||
|
- target: WristCode
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: org.gbrown.wristcode.tests
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
TEST_HOST: $(BUILT_PRODUCTS_DIR)/WristCode.app/WristCode
|
||||||
|
BUNDLE_LOADER: $(TEST_HOST)
|
||||||
|
schemes:
|
||||||
|
WristCode:
|
||||||
|
build:
|
||||||
|
targets:
|
||||||
|
WristCode: all
|
||||||
|
WristCodeTests: [test]
|
||||||
|
run:
|
||||||
|
config: Debug
|
||||||
|
test:
|
||||||
|
config: Debug
|
||||||
|
targets:
|
||||||
|
- WristCodeTests
|
||||||
|
archive:
|
||||||
|
config: Release
|
||||||
Reference in new issue
Block a user