Watch: status, environment and notifications screens
A gear on the feed opens a status overview that leads with what is wrong: offline hosts, hosts on a fallback route, tokens expiring within 7 days, and push problems, with healthy hosts folded into one row. Each host opens its route ladder with Reconnect. The notifications page shows registration, the last push, Send test and Unpair. Demo mode gains a Laptop on its VPN fallback, and snapshot tests render the three screens.
This commit is contained in:
1 parent
9707daf1fe
commit
7ca0362745
17 files changed
+1063
-14
No files matched your search
@@ -52,6 +52,12 @@ protocol GatewayAPI: Sendable {
|
||||
func projects() async throws -> ProjectsResponse
|
||||
func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse
|
||||
func registerPush(_ registration: PushRegistration) async throws
|
||||
func status() async throws -> StatusResponse
|
||||
/// Connects the host again from its first route.
|
||||
func reconnect(hostId: String) async throws
|
||||
func testPush() async throws -> PushResult
|
||||
/// Revokes this watch's device token.
|
||||
func unpair() async throws
|
||||
}
|
||||
|
||||
struct GatewayClient: GatewayAPI {
|
||||
@@ -101,6 +107,22 @@ struct GatewayClient: GatewayAPI {
|
||||
try await callIgnoringBody("PUT", ["v1", "device", "push"], body: registration)
|
||||
}
|
||||
|
||||
func status() async throws -> StatusResponse {
|
||||
try await call("GET", ["v1", "status"])
|
||||
}
|
||||
|
||||
func reconnect(hostId: String) async throws {
|
||||
try await callIgnoringBody("POST", ["v1", "hosts", hostId, "reconnect"], body: nil as SendMessageRequest?)
|
||||
}
|
||||
|
||||
func testPush() async throws -> PushResult {
|
||||
try await call("POST", ["v1", "device", "test-push"])
|
||||
}
|
||||
|
||||
func unpair() async throws {
|
||||
try await callIgnoringBody("DELETE", ["v1", "device"], body: nil as SendMessageRequest?)
|
||||
}
|
||||
|
||||
// MARK: - Plumbing
|
||||
|
||||
private func call<Response: Decodable>(_ method: String, _ path: [String]) async throws -> Response {
|
||||
|
||||
@@ -230,6 +230,111 @@ struct ProjectsResponse: Codable, Hashable, Sendable {
|
||||
var hosts: [Host]
|
||||
}
|
||||
|
||||
/// How a route reaches its host, guessed by the gateway from the URL.
|
||||
struct RouteKind: RawRepresentable, Codable, Hashable, Sendable {
|
||||
let rawValue: String
|
||||
init(rawValue: String) { self.rawValue = rawValue }
|
||||
|
||||
static let direct = RouteKind(rawValue: "direct")
|
||||
static let vpn = RouteKind(rawValue: "vpn")
|
||||
static let `public` = RouteKind(rawValue: "public")
|
||||
|
||||
/// "direct", "VPN" or "public", for sentences like "Laptop is on its VPN route".
|
||||
var name: String { self == .vpn ? "VPN" : rawValue }
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .direct: "house.fill"
|
||||
case .vpn: "lock.shield.fill"
|
||||
default: "cloud.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One way to reach a host. `Route` in the contract, renamed here because `Route` is the app's
|
||||
/// navigation.
|
||||
struct HostRoute: Codable, Hashable, Sendable {
|
||||
struct Result: RawRepresentable, Codable, Hashable, Sendable {
|
||||
let rawValue: String
|
||||
init(rawValue: String) { self.rawValue = rawValue }
|
||||
|
||||
static let ok = Result(rawValue: "ok")
|
||||
static let failed = Result(rawValue: "failed")
|
||||
static let untried = Result(rawValue: "untried")
|
||||
}
|
||||
|
||||
var url: String
|
||||
var kind: RouteKind
|
||||
/// From the latest connect. `untried` when an earlier route answered first.
|
||||
var result: Result
|
||||
var error: String?
|
||||
var checkedAt: Date?
|
||||
|
||||
/// The URL without its scheme, such as `laptop.gib:3773`.
|
||||
var hostPort: String {
|
||||
guard let components = URLComponents(string: url), let host = components.host else { return url }
|
||||
return components.port.map { "\(host):\($0)" } ?? host
|
||||
}
|
||||
}
|
||||
|
||||
/// One host as the status screens show it.
|
||||
struct HostStatusDetail: Codable, Hashable, Sendable, Identifiable {
|
||||
var id: String
|
||||
var label: String
|
||||
var status: HostStatus
|
||||
var error: String?
|
||||
/// The route in use. Null unless online.
|
||||
var activeUrl: String?
|
||||
/// Preferred first, the order the gateway tries them.
|
||||
var routes: [HostRoute]
|
||||
var onlineSince: Date?
|
||||
var lastOnlineAt: Date?
|
||||
/// When the gateway tries again, while it waits to retry.
|
||||
var nextRetryAt: Date?
|
||||
var tokenExpiresAt: Date?
|
||||
var serverVersion: String?
|
||||
|
||||
var activeRoute: HostRoute? { routes.first { $0.url == activeUrl } }
|
||||
|
||||
/// Online, but not on the first route.
|
||||
var onFallback: Bool {
|
||||
status == .online && activeUrl != nil && activeUrl != routes.first?.url
|
||||
}
|
||||
}
|
||||
|
||||
/// What Apple answered for one push.
|
||||
struct PushResult: Codable, Hashable, Sendable {
|
||||
var ok: Bool
|
||||
var at: Date
|
||||
/// Apple's HTTP status, or 0 when the push never reached Apple.
|
||||
var status: Int
|
||||
/// Apple's reason, such as `BadDeviceToken`, or why the gateway could not send.
|
||||
var reason: String?
|
||||
}
|
||||
|
||||
/// `GET /v1/status`: everything the settings screens show.
|
||||
struct StatusResponse: Codable, Hashable, Sendable {
|
||||
struct Gateway: Codable, Hashable, Sendable {
|
||||
var startedAt: Date
|
||||
/// False when the gateway has no APNs key, so nothing can be pushed.
|
||||
var pushConfigured: Bool
|
||||
var lastPush: PushResult?
|
||||
}
|
||||
|
||||
/// The watch asking.
|
||||
struct Device: Codable, Hashable, Sendable {
|
||||
var name: String
|
||||
var pairedAt: Date
|
||||
/// True once the watch has sent its APNs token.
|
||||
var pushRegistered: Bool
|
||||
var apnsEnvironment: PushRegistration.Environment?
|
||||
}
|
||||
|
||||
var hosts: [HostStatusDetail]
|
||||
var gateway: Gateway
|
||||
var device: Device
|
||||
}
|
||||
|
||||
struct PairRequest: Codable, Sendable {
|
||||
var code: String
|
||||
var deviceName: String
|
||||
@@ -303,7 +408,7 @@ struct ErrorResponse: Codable, Sendable {
|
||||
|
||||
/// Notification categories the watch registers actions for.
|
||||
enum PushCategory: String, CaseIterable, Sendable {
|
||||
case approval, finished, failed, input, host
|
||||
case approval, finished, failed, input, host, test
|
||||
}
|
||||
|
||||
enum JSON {
|
||||
|
||||
@@ -5,10 +5,15 @@ import Foundation
|
||||
enum Route: Hashable, Sendable {
|
||||
case thread(hostId: String, threadId: String)
|
||||
case newThread
|
||||
/// The status overview, and from it one host or the notifications page.
|
||||
case status
|
||||
case host(id: String)
|
||||
case notifications
|
||||
|
||||
static let scheme = "wristcode"
|
||||
|
||||
/// `wristcode://thread/<hostId>/<threadId>` or `wristcode://new`.
|
||||
/// `wristcode://thread/<hostId>/<threadId>`, `wristcode://new`, `wristcode://status`,
|
||||
/// `wristcode://host/<hostId>` or `wristcode://notifications`.
|
||||
var url: URL {
|
||||
var components = URLComponents()
|
||||
components.scheme = Self.scheme
|
||||
@@ -20,6 +25,13 @@ enum Route: Hashable, Sendable {
|
||||
.joined(separator: "/")
|
||||
case .newThread:
|
||||
components.host = "new"
|
||||
case .status:
|
||||
components.host = "status"
|
||||
case let .host(id):
|
||||
components.host = "host"
|
||||
components.percentEncodedPath = "/" + (id.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? id)
|
||||
case .notifications:
|
||||
components.host = "notifications"
|
||||
}
|
||||
return components.url!
|
||||
}
|
||||
@@ -35,6 +47,12 @@ enum Route: Hashable, Sendable {
|
||||
self = .thread(hostId: parts[0], threadId: parts[1])
|
||||
case "new":
|
||||
self = .newThread
|
||||
case "status":
|
||||
self = .status
|
||||
case "host" where parts.count == 1 && !parts[0].isEmpty:
|
||||
self = .host(id: parts[0])
|
||||
case "notifications":
|
||||
self = .notifications
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user