diff --git a/watch/README.md b/watch/README.md index e2d7699..0e372bd 100644 --- a/watch/README.md +++ b/watch/README.md @@ -45,10 +45,13 @@ the pairing screen has a "Try the demo" button, and launch arguments open a scre xcrun simctl launch org.gbrown.wristcode.watchkitapp -demo # feed xcrun simctl launch org.gbrown.wristcode.watchkitapp -demo -thread littleman/t-next # thread xcrun simctl launch org.gbrown.wristcode.watchkitapp -demo -screen new # new thread +xcrun simctl launch org.gbrown.wristcode.watchkitapp -demo -screen status # status overview +xcrun simctl launch org.gbrown.wristcode.watchkitapp -demo -host laptop # one environment +xcrun simctl launch org.gbrown.wristcode.watchkitapp -demo -screen notifications # notifications ``` -`screenshots/` holds the screens captured this way. The complication and the multi-select question -are rendered by `SnapshotTests` instead, which only run when given a directory: +`screenshots/` holds the screens captured this way. The complication, the multi-select question and +the status screens are rendered by `SnapshotTests` instead, which only run when given a directory: ```sh TEST_RUNNER_WC_SCREENSHOT_DIR=$PWD/screenshots xcodebuild -scheme WristCode \ @@ -58,6 +61,15 @@ TEST_RUNNER_WC_SCREENSHOT_DIR=$PWD/screenshots xcodebuild -scheme WristCode \ In demo mode the complication shows the demo counts too. +## Status and settings + +The gear at the top left of the feed opens the status overview (`GET /v1/status`). It leads with +"All connected" or "Needs attention", lists only the issues worst first (offline hosts, a host on a +fallback route, tokens expiring within 7 days, push problems), and folds the healthy hosts into one +row. Each host opens its route ladder with Reconnect. The Notifications row shows whether this watch +is registered, the last push, Send test, the gateway, and Unpair. Each screen loads once when it +appears; nothing polls. + ## Complication `WristCodeWidget` shows how many threads wait on you, from `GET /v1/summary`, in the circular, @@ -87,6 +99,7 @@ To add it: long-press the watch face, Edit, swipe to Complications, pick a slot, | `Auth/DeviceStore.swift` | Device token and gateway URL in the shared Keychain group | | `Shared/SharedState.swift` | Demo flag and last summary in the shared Keychain group, for the complication | | `Demo/DemoGateway.swift` | In-memory gateway with fixtures | -| `Views/` | Pair, Feed, Thread, New thread, and the mock B theme | -| `Notifications/NotificationHandler.swift` | Categories `approval`, `finished`, `failed`, `input`, `host` and their actions | +| `Views/` | Pair, Feed, Thread, New thread, Status, and the mock B theme | +| `Views/StatusReport.swift` | What the status overview says: headline, issues worst first, healthy hosts | +| `Notifications/NotificationHandler.swift` | Categories `approval`, `finished`, `failed`, `input`, `host`, `test` and their actions | | `../WristCodeWidget/` | The complication: timeline provider, and one view per family | diff --git a/watch/WristCode/API/GatewayClient.swift b/watch/WristCode/API/GatewayClient.swift index 150107a..55c9e93 100644 --- a/watch/WristCode/API/GatewayClient.swift +++ b/watch/WristCode/API/GatewayClient.swift @@ -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(_ method: String, _ path: [String]) async throws -> Response { diff --git a/watch/WristCode/API/Models.swift b/watch/WristCode/API/Models.swift index 4c97c6b..aa3b79b 100644 --- a/watch/WristCode/API/Models.swift +++ b/watch/WristCode/API/Models.swift @@ -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 { diff --git a/watch/WristCode/API/Route.swift b/watch/WristCode/API/Route.swift index e73da50..c082f4f 100644 --- a/watch/WristCode/API/Route.swift +++ b/watch/WristCode/API/Route.swift @@ -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//` or `wristcode://new`. + /// `wristcode://thread//`, `wristcode://new`, `wristcode://status`, + /// `wristcode://host/` 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 } diff --git a/watch/WristCode/AppModel.swift b/watch/WristCode/AppModel.swift index c57599a..d80113c 100644 --- a/watch/WristCode/AppModel.swift +++ b/watch/WristCode/AppModel.swift @@ -37,18 +37,29 @@ final class AppModel { self.path = path } - /// `-demo` starts in demo mode. `-screen new` or `-thread /` opens that - /// screen, which is how the screenshots in watch/screenshots are taken. + /// `-demo` starts in demo mode. `-screen new|status|notifications`, `-host ` or + /// `-thread /` opens that screen, which is how the screenshots in + /// watch/screenshots are taken. convenience init(launchArguments args: [String]) { DeviceStore.migrateToSharedGroup() 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] + func argument(_ flag: String) -> String? { + guard demo, let i = args.firstIndex(of: flag), i + 1 < args.count else { return nil } + return args[i + 1] } - if demo, let i = args.firstIndex(of: "-thread"), i + 1 < args.count { - let parts = args[i + 1].split(separator: "/", maxSplits: 1).map(String.init) + var path: [Route] = [] + switch argument("-screen") { + case "new": path = [.newThread] + case "status": path = [.status] + case "notifications": path = [.status, .notifications] + default: break + } + if let hostId = argument("-host") { + path = [.status, .host(id: hostId)] + } + if let thread = argument("-thread") { + let parts = thread.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) @@ -101,6 +112,19 @@ final class AppModel { path = [] } + /// Revokes the device token on the gateway, then signs out. Signs out even when the gateway + /// cannot be reached, since the token is forgotten either way. + func unpair() async { + if isPaired { try? await api.unpair() } + signOut() + } + + /// The gateway's host name, such as `wristcode.gbrown.org`. Nil in demo mode. + var gatewayHost: String? { + if case let .paired(credentials) = mode { return credentials.gatewayURL.host() } + return nil + } + /// 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 } diff --git a/watch/WristCode/Demo/DemoGateway.swift b/watch/WristCode/Demo/DemoGateway.swift index 043c902..c0a6d8b 100644 --- a/watch/WristCode/Demo/DemoGateway.swift +++ b/watch/WristCode/Demo/DemoGateway.swift @@ -11,6 +11,7 @@ actor DemoGateway: GatewayAPI { private var messages: [String: [Message]] private let hosts: [HostSummary] private let projectHosts: [ProjectsResponse.Host] + private var statusResponse: StatusResponse private var nextId = 1 init(now: Date = .now) { @@ -19,6 +20,7 @@ actor DemoGateway: GatewayAPI { messages = fixtures.messages hosts = fixtures.hosts projectHosts = fixtures.projectHosts + statusResponse = fixtures.status } func feed() async throws -> Feed { @@ -93,6 +95,20 @@ actor DemoGateway: GatewayAPI { func registerPush(_ registration: PushRegistration) async throws {} + func status() async throws -> StatusResponse { + statusResponse + } + + func reconnect(hostId: String) async throws {} + + func testPush() async throws -> PushResult { + let result = PushResult(ok: true, at: .now, status: 200, reason: nil) + statusResponse.gateway.lastPush = result + return result + } + + func unpair() async throws {} + // MARK: - Helpers private func item(_ hostId: String, _ threadId: String) -> FeedItem? { @@ -122,6 +138,7 @@ struct DemoFixtures { let messages: [String: [Message]] let hosts: [HostSummary] let projectHosts: [ProjectsResponse.Host] + let status: StatusResponse init(now: Date) { func ago(_ minutes: Double) -> Date { now.addingTimeInterval(-minutes * 60) } @@ -129,6 +146,7 @@ struct DemoFixtures { 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: "laptop", label: "Laptop", status: .online, error: nil), HostSummary(id: "littleman", label: "LittleMan", status: .offline, error: "Connection refused"), ] @@ -251,6 +269,42 @@ struct DemoFixtures { id: "littleman", label: "LittleMan", status: .offline, error: "Connection refused", projects: [.init(id: "p-site", title: "gbrown.org", model: "claude-opus-5-5")]), ] + + // Mac mini and server-gib on their first route, Laptop on its VPN fallback, LittleMan offline. + func route(_ url: String, _ kind: RouteKind, _ result: HostRoute.Result, _ error: String? = nil, _ minutes: Double? = nil) -> HostRoute { + HostRoute(url: url, kind: kind, result: result, error: error, checkedAt: minutes.map(ago)) + } + func days(_ count: Double) -> Date { now.addingTimeInterval(count * 86_400) } + status = StatusResponse( + hosts: [ + HostStatusDetail( + id: "mac-mini", label: "Mac mini", status: .online, error: nil, activeUrl: "http://mac.gib:3773", + routes: [route("http://mac.gib:3773", .direct, .ok, nil, 95), route("https://t3mac.gbrown.org", .public, .untried)], + onlineSince: ago(95), lastOnlineAt: nil, nextRetryAt: nil, tokenExpiresAt: days(29), serverVersion: "0.1.5"), + HostStatusDetail( + id: "server-gib", label: "server-gib", status: .online, error: nil, activeUrl: "http://server.gib:3773", + routes: [route("http://server.gib:3773", .direct, .ok, nil, 300), route("https://t3server.gbrown.org", .public, .untried)], + onlineSince: ago(300), lastOnlineAt: nil, nextRetryAt: nil, tokenExpiresAt: days(5.5), serverVersion: "0.1.5"), + HostStatusDetail( + id: "laptop", label: "Laptop", status: .online, error: nil, activeUrl: "http://laptopvpn.gib:3773", + routes: [ + route("http://laptop.gib:3773", .direct, .failed, "no answer in 5 s", 29), + route("http://laptopvpn.gib:3773", .vpn, .ok, nil, 29), + route("https://t3laptop.gbrown.org", .public, .untried), + route("https://t3laptopvpn.gbrown.org", .public, .untried), + ], + onlineSince: ago(29), lastOnlineAt: nil, nextRetryAt: nil, tokenExpiresAt: days(29.5), serverVersion: "0.1.5"), + HostStatusDetail( + id: "littleman", label: "LittleMan", status: .offline, error: "Connection refused", activeUrl: nil, + routes: [ + route("http://littleman.gib:3773", .direct, .failed, "refused", 0.3), + route("https://t3littleman.gbrown.org", .public, .failed, "502", 0.3), + ], + onlineSince: nil, lastOnlineAt: ago(4), nextRetryAt: now.addingTimeInterval(40), + tokenExpiresAt: days(29.5), serverVersion: "0.1.4"), + ], + gateway: .init(startedAt: ago(600), pushConfigured: true, lastPush: PushResult(ok: true, at: ago(2), status: 200, reason: nil)), + device: .init(name: "Apple Watch", pairedAt: days(-3), pushRegistered: true, apnsEnvironment: .production)) } /// A feed for previews, without going through the actor. diff --git a/watch/WristCode/Notifications/NotificationHandler.swift b/watch/WristCode/Notifications/NotificationHandler.swift index 341091b..dfdc5cf 100644 --- a/watch/WristCode/Notifications/NotificationHandler.swift +++ b/watch/WristCode/Notifications/NotificationHandler.swift @@ -76,6 +76,7 @@ final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate { category(.finished, [reply, open]), category(.failed, [open]), category(.host, []), + category(.test, []), ] } diff --git a/watch/WristCode/Views/FeedView.swift b/watch/WristCode/Views/FeedView.swift index 11792f1..d4ca8de 100644 --- a/watch/WristCode/Views/FeedView.swift +++ b/watch/WristCode/Views/FeedView.swift @@ -38,6 +38,12 @@ struct FeedView: View { .listStyle(.plain) .refreshable { await model.refreshFeed() } .toolbar { + ToolbarItem(placement: .topBarLeading) { + NavigationLink(value: Route.status) { + Image(systemName: "gearshape.fill") + } + .accessibilityLabel("Status and settings") + } ToolbarItem(placement: .topBarTrailing) { NavigationLink(value: Route.newThread) { Image(systemName: "plus") diff --git a/watch/WristCode/Views/StatusReport.swift b/watch/WristCode/Views/StatusReport.swift new file mode 100644 index 0000000..9ca35aa --- /dev/null +++ b/watch/WristCode/Views/StatusReport.swift @@ -0,0 +1,185 @@ +// What the status overview says about a `StatusResponse`: a headline, the issues worst first, and +// the healthy hosts folded into one row. Worked out at render, so relative times stay current. + +import Foundation + +struct StatusIssue: Hashable, Identifiable { + enum Severity: Int, Comparable { + case warning, problem + static func < (a: Self, b: Self) -> Bool { a.rawValue < b.rawValue } + } + + enum Kind: Hashable { + case host, fallback, token, push + } + + var id: String + var severity: Severity + var kind: Kind + var title: String + var detail: String? + /// The screen that explains it. + var route: Route +} + +struct StatusReport { + let status: StatusResponse + let now: Date + + init(_ status: StatusResponse, now: Date = .now) { + self.status = status + self.now = now + } + + /// A host on its fallback route is still connected, so only that leaves the headline alone. + var allConnected: Bool { issues.allSatisfy { $0.kind == .fallback } } + + var worst: StatusIssue.Severity? { issues.map(\.severity).max() } + + /// "5 environments · pushes OK". + var subline: String { + let count = status.hosts.count + return "\(count) environment\(count == 1 ? "" : "s") · \(pushSummary)" + } + + var pushesOK: Bool { pushIssues.isEmpty } + + var pushSummary: String { + if !status.gateway.pushConfigured { return "pushes off" } + if !status.device.pushRegistered { return "watch not registered" } + if status.gateway.lastPush?.ok == false { return "last push failed" } + return "pushes OK" + } + + var issues: [StatusIssue] { + var issues = status.hosts.compactMap(hostIssue) + if let token = tokenIssue { issues.append(token) } + issues += pushIssues + // Stable within a severity, so hosts keep the gateway's order. + return issues.enumerated() + .sorted { a, b in + a.element.severity == b.element.severity ? a.offset < b.offset : a.element.severity > b.element.severity + } + .map(\.element) + } + + /// Hosts with nothing to say: online on their first route. + var healthy: [HostStatusDetail] { + status.hosts.filter { $0.status == .online && !$0.onFallback } + } + + /// "4 more, all direct", or "all online" when they use different kinds of route. + var healthySummary: String? { + let hosts = healthy + guard !hosts.isEmpty else { return nil } + let kinds = Set(hosts.compactMap { $0.activeRoute?.kind }) + let how = kinds.count == 1 ? "all \(kinds.first!.name)" : "all online" + let anyHostIssue = issues.contains { $0.kind == .host || $0.kind == .fallback } + if anyHostIssue { return "\(hosts.count) more, \(how)" } + return "\(hosts.count) environment\(hosts.count == 1 ? "" : "s"), \(how)" + } + + // MARK: - Issues + + private func hostIssue(_ host: HostStatusDetail) -> StatusIssue? { + let route = Route.host(id: host.id) + if host.onFallback, let active = host.activeRoute { + return StatusIssue( + id: "fallback-\(host.id)", severity: .warning, kind: .fallback, + title: "\(host.label) is on its \(active.kind.name) route", + detail: host.routes.first.map(Self.failure), route: route) + } + switch host.status { + case .online: + return nil + case .connecting: + return StatusIssue( + id: "host-\(host.id)", severity: .warning, kind: .host, + title: "\(host.label) connecting", detail: nil, route: route) + case .authInvalid: + return StatusIssue( + id: "host-\(host.id)", severity: .problem, kind: .host, + title: "\(host.label) needs a new token", detail: "Run renew-hosts.sh", route: route) + case .incompatible: + return StatusIssue( + id: "host-\(host.id)", severity: .problem, kind: .host, + title: "\(host.label) is incompatible", detail: host.error, route: route) + default: + return StatusIssue( + id: "host-\(host.id)", severity: .problem, kind: .host, + title: "\(host.label) offline", detail: offlineDetail(host), route: route) + } + } + + /// "Both routes failed · 4 min", or the error when no route was tried. + private func offlineDetail(_ host: HostStatusDetail) -> String? { + let failed = host.routes.filter { $0.result == .failed }.count + let cause: String? = switch failed { + case 0: host.error + case host.routes.count: host.routes.count == 1 ? "Its route failed" : host.routes.count == 2 ? "Both routes failed" : "All \(failed) routes failed" + default: "\(failed) of \(host.routes.count) routes failed" + } + let since = host.lastOnlineAt.map { Format.age(of: $0, now: now) } + return [cause, since].compactMap(\.self).joined(separator: " · ").nilIfEmpty + } + + /// Tokens that expire within 7 days, as one issue. + private var tokenIssue: StatusIssue? { + let soon = status.hosts.compactMap { host in + host.tokenExpiresAt.map { (host, $0) } + } + .filter { $0.1.timeIntervalSince(now) < 7 * 86_400 } + .sorted { $0.1 < $1.1 } + guard let (first, expiry) = soon.first else { return nil } + let when = Format.expiry(expiry, now: now) + let title = soon.count == 1 ? "\(first.label) token \(when)" : "\(soon.count) tokens \(when)" + return StatusIssue( + id: "token", severity: .warning, kind: .token, title: title, + detail: "Run renew-hosts.sh", route: soon.count == 1 ? .host(id: first.id) : .status) + } + + private var pushIssues: [StatusIssue] { + let gateway = status.gateway + if !gateway.pushConfigured { + return [StatusIssue( + id: "push", severity: .problem, kind: .push, title: "Pushes are off", + detail: "The gateway has no APNs key", route: .notifications)] + } + if !status.device.pushRegistered { + return [StatusIssue( + id: "push", severity: .warning, kind: .push, title: "This watch gets no pushes", + detail: "It has not registered with the gateway", route: .notifications)] + } + if let push = gateway.lastPush, !push.ok { + return [StatusIssue( + id: "push", severity: .problem, kind: .push, title: "Last push failed", + detail: push.reason ?? "Status \(push.status)", route: .notifications)] + } + return [] + } + + /// "laptop.gib did not answer", or the host and its error. + static func failure(of route: HostRoute) -> String { + guard let error = route.error else { return "\(route.hostPort) did not connect" } + if error.hasPrefix("no answer") { return "\(route.hostPort) did not answer" } + return "\(route.hostPort): \(error)" + } +} + +extension PushResult { + /// A short hint for a failed push, when the cause is a common one. + var hint: String? { + guard !ok else { return nil } + if status == 0 { return "The push never reached Apple." } + return switch reason { + case "BadDeviceToken": "Usually a sandbox/production mismatch. Re-pair this build." + case "Unregistered": "Apple no longer knows this watch. Reopen the app to register again." + case "DeviceTokenNotForTopic": "The gateway's APNs topic is not this app's bundle ID." + default: nil + } + } +} + +private extension String { + var nilIfEmpty: String? { isEmpty ? nil : self } +} diff --git a/watch/WristCode/Views/StatusView.swift b/watch/WristCode/Views/StatusView.swift new file mode 100644 index 0000000..e2937e0 --- /dev/null +++ b/watch/WristCode/Views/StatusView.swift @@ -0,0 +1,506 @@ +// Settings mock C: an overview that leads with what is wrong, an environment page with mock B's +// route ladder, and a notifications page with a test push and Unpair. Each screen loads +// `GET /v1/status` when it appears; nothing polls. + +import SwiftUI + +/// The overview: a headline, the issues worst first, then the healthy hosts folded into one row. +struct StatusView: View { + @Environment(AppModel.self) private var model + @State private var expanded = false + var status: StatusResponse? + var scrolls = true + + var body: some View { + StatusLoader(status: status, scrolls: scrolls) { status in + let report = StatusReport(status) + Headline(report: report) + + ForEach(report.issues) { issue in + Button { model.path.append(issue.route) } label: { + Card(edge: issue.severity == .problem ? Theme.bad : Theme.you) { + RowText(title: issue.title, detail: issue.detail) + } + } + .buttonStyle(.plain) + } + + if let summary = report.healthySummary { + Button { expanded.toggle() } label: { + Card(edge: Theme.done) { + HStack { + RowText(title: summary) + Spacer(minLength: 4) + Image(systemName: expanded ? "chevron.up" : "chevron.down") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(Theme.secondaryText) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + if expanded { + ForEach(report.healthy) { host in + Button { model.path.append(.host(id: host.id)) } label: { + Card(edge: Theme.work) { + HStack(spacing: 6) { + if let kind = host.activeRoute?.kind { + Image(systemName: kind.symbol) + .font(.system(size: 11)) + .foregroundStyle(Theme.work) + } + RowText(title: host.label) + } + } + } + .buttonStyle(.plain) + } + } + } + + Button { model.path.append(.notifications) } label: { + Card(edge: Theme.done) { + HStack(spacing: 6) { + Image(systemName: "bell.fill") + .font(.system(size: 11)) + .foregroundStyle(Theme.secondaryText) + RowText(title: "Notifications") + Spacer(minLength: 4) + Text(report.pushesOK ? "OK" : "Check") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(report.pushesOK ? Theme.work : Theme.you) + } + } + } + .buttonStyle(.plain) + .padding(.top, 4) + } + .navigationTitle("Status") + } +} + +private struct Headline: View { + var report: StatusReport + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(report.allConnected ? "All connected" : "Needs attention") + .font(.system(size: 20, weight: .heavy, design: .rounded)) + .foregroundStyle(report.allConnected ? Theme.work : report.worst == .problem ? Theme.bad : Theme.you) + Text(report.subline) + .font(.system(size: 12)) + .foregroundStyle(Theme.secondaryText) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + .padding(.bottom, 4) + .accessibilityElement(children: .combine) + } +} + +/// One environment: where it stands, its routes as a ladder, and Reconnect. +struct HostStatusView: View { + @Environment(AppModel.self) private var model + let hostId: String + var status: StatusResponse? + var scrolls = true + @State private var reload = 0 + @State private var reconnecting = false + @State private var error: String? + + var body: some View { + StatusLoader(reload: reload, status: status, scrolls: scrolls) { status in + if let host = status.hosts.first(where: { $0.id == hostId }) { + content(host, now: .now) + } else { + RowText(title: "This environment is gone.") + } + } + } + + @ViewBuilder + private func content(_ host: HostStatusDetail, now: Date) -> some View { + let line = statusLine(host) + Text(line.text) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(line.color) + .padding(.horizontal, 4) + .navigationTitle(host.label) + + RouteLadder(host: host) + .padding(.horizontal, 4) + .padding(.vertical, 4) + + VStack(alignment: .leading, spacing: 2) { + if host.status != .online { + let parts = [ + host.nextRetryAt.map { "Next try in \(Format.countdown(to: $0, now: now))" }, + host.lastOnlineAt.map { "last online \($0.formatted(date: .omitted, time: .shortened))" }, + ].compactMap(\.self) + if !parts.isEmpty { + Text(parts.joined(separator: " · ")) + .foregroundStyle(Theme.secondaryText) + } + } + if let expiry = host.tokenExpiresAt { + let days = expiry.timeIntervalSince(now) / 86_400 + Text("Token \(Format.expiry(expiry, now: now))") + .foregroundStyle(days < 0 ? Theme.bad : days < 7 ? Theme.you : Theme.secondaryText) + } + if let version = host.serverVersion { + Text("T3 \(version)") + .foregroundStyle(Theme.secondaryText) + } + } + .font(.system(size: 11)) + .padding(.horizontal, 4) + + Button(reconnecting ? "Reconnecting" : "Reconnect") { reconnect() } + .buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white)) + .disabled(reconnecting) + .padding(.top, 6) + + if let error { + Text(error) + .font(.system(size: 11)) + .foregroundStyle(Theme.secondaryText) + } + } + + private func statusLine(_ host: HostStatusDetail) -> (text: String, color: Color) { + switch host.status { + case .online: + let kind = host.activeRoute?.kind.name ?? "direct" + return host.onFallback ? ("On its \(kind) route", Theme.you) : ("Online · \(kind)", Theme.work) + case .connecting: + return ("Connecting", Theme.secondaryText) + case .authInvalid: + return ("Needs a new token", Theme.bad) + case .incompatible: + return (host.error.map { "Incompatible: \($0)" } ?? "Incompatible", Theme.bad) + default: + return ("Offline", Theme.bad) + } + } + + /// Asks the gateway to start again from the first route, then looks again once it has had a + /// moment to try. + private func reconnect() { + reconnecting = true + error = nil + Task { + do { + try await model.api.reconnect(hostId: hostId) + try await Task.sleep(for: .seconds(2)) + reload += 1 + } catch { + self.error = model.message(for: error) + } + reconnecting = false + } + } +} + +/// The routes in the order the gateway walks them, each with its kind and what happened. +private struct RouteLadder: View { + var host: HostStatusDetail + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + ForEach(Array(host.routes.enumerated()), id: \.offset) { _, route in + HStack(alignment: .top, spacing: 7) { + Image(systemName: route.kind.symbol) + .font(.system(size: 10)) + .foregroundStyle(color(route)) + .frame(width: 20, height: 20) + .background(Circle().fill(.black)) + VStack(alignment: .leading, spacing: 1) { + Text(route.hostPort) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(route.result == .untried ? Theme.done : route.result == .ok ? Theme.work : .white) + .lineLimit(1) + .minimumScaleFactor(0.8) + if let detail = detail(route) { + Text(detail) + .font(.system(size: 11)) + .foregroundStyle(color(route)) + } + } + .padding(.top, 2) + } + } + } + .background(alignment: .leading) { + Rectangle() + .fill(Color.white.opacity(0.18)) + .frame(width: 2) + .padding(.leading, 9) + .padding(.vertical, 10) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func color(_ route: HostRoute) -> Color { + switch route.result { + case .ok: Theme.work + case .failed: Theme.bad + default: Theme.done + } + } + + private func detail(_ route: HostRoute) -> String? { + switch route.result { + case .failed: + return route.error ?? "failed" + case .ok: + let at = route.checkedAt ?? host.onlineSince + return at.map { "connected \($0.formatted(date: .omitted, time: .shortened))" } ?? "connected" + default: + return nil + } + } +} + +/// Whether this watch gets pushes, the last push, a test push, the gateway, and Unpair. +struct NotificationsView: View { + @Environment(AppModel.self) private var model + var status: StatusResponse? + var scrolls = true + @State private var reload = 0 + @State private var sending = false + @State private var result: PushResult? + @State private var error: String? + @State private var confirmingUnpair = false + + /// `status` and `result` start the screen loaded and after a test push, for snapshots. + init(status: StatusResponse? = nil, result: PushResult? = nil, scrolls: Bool = true) { + self.status = status + self.scrolls = scrolls + _result = State(initialValue: result) + } + + var body: some View { + StatusLoader(reload: reload, status: status, scrolls: scrolls) { status in + content(status, now: .now) + } + .navigationTitle("Notifications") + .confirmationDialog("Unpair this watch?", isPresented: $confirmingUnpair) { + Button("Unpair", role: .destructive) { Task { await model.unpair() } } + } message: { + Text("It stops getting notifications until you pair it again.") + } + } + + @ViewBuilder + private func content(_ status: StatusResponse, now: Date) -> some View { + let device = status.device + Card(edge: device.pushRegistered ? Theme.work : Theme.you) { + HStack { + RowText(title: "This watch", detail: device.apnsEnvironment?.rawValue) + Spacer(minLength: 4) + Text(device.pushRegistered ? "registered" : "not registered") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(device.pushRegistered ? Theme.work : Theme.you) + } + } + + if !status.gateway.pushConfigured { + Card(edge: Theme.bad) { + RowText(title: "Pushes are off", detail: "The gateway has no APNs key") + } + } + + let last = status.gateway.lastPush + Card(edge: last.map { $0.ok ? Theme.work : Theme.bad } ?? Theme.done) { + HStack { + RowText(title: "Last push") + Spacer(minLength: 4) + if let last { + let age = Format.age(of: last.at, now: now) + Text("\(age == "now" ? "just now" : "\(age) ago") \(last.ok ? "✓" : "✗")") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(last.ok ? Theme.work : Theme.bad) + } else { + Text("none yet") + .font(.system(size: 11)) + .foregroundStyle(Theme.secondaryText) + } + } + if let last, !last.ok { + Text([last.reason ?? "Status \(last.status)", last.hint].compactMap(\.self).joined(separator: ". ")) + .font(.system(size: 11)) + .foregroundStyle(Theme.secondaryText) + .padding(.top, 2) + } + } + + Button(sending ? "Sending" : "Send test") { sendTest() } + .buttonStyle(PillButtonStyle(fill: Theme.you)) + .disabled(sending) + .padding(.top, 4) + + if let result { + VStack(spacing: 2) { + if result.ok { + Text("Apple accepted it at \(result.at.formatted(.dateTime.hour().minute().second()))") + .foregroundStyle(Theme.work) + } else { + Text(result.reason ?? "Status \(result.status)") + .foregroundStyle(Theme.bad) + if let hint = result.hint { + Text(hint) + .foregroundStyle(Theme.secondaryText) + } + } + } + .font(.system(size: 11)) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + } + if let error { + Text(error) + .font(.system(size: 11)) + .foregroundStyle(Theme.secondaryText) + } + + HStack { + Text("Gateway") + .font(.system(size: 12, weight: .semibold)) + Spacer(minLength: 4) + Text(model.gatewayHost ?? "demo") + .font(.system(size: 11)) + .foregroundStyle(Theme.secondaryText) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .padding(.horizontal, 8) + .padding(.top, 8) + + Button("Unpair") { confirmingUnpair = true } + .buttonStyle(PillButtonStyle(fill: Theme.control, foreground: Theme.bad)) + } + + private func sendTest() { + sending = true + result = nil + error = nil + Task { + do { + result = try await model.api.testPush() + reload += 1 + } catch { + self.error = model.message(for: error) + } + sending = false + } + } +} + +/// Loads the status when it appears, and again whenever `reload` changes, then shows `content`. +/// The last status stays on screen while a reload runs. +private struct StatusLoader: View { + @Environment(AppModel.self) private var model + var reload: Int + /// Snapshots render the column without its ScrollView, which ImageRenderer leaves blank. + var scrolls: Bool + var content: (StatusResponse) -> Content + @State private var status: StatusResponse? + @State private var error: String? + + /// `status` starts the screen loaded, for snapshots and previews. + init( + reload: Int = 0, status: StatusResponse? = nil, scrolls: Bool = true, + @ViewBuilder content: @escaping (StatusResponse) -> Content + ) { + self.reload = reload + self.scrolls = scrolls + self.content = content + _status = State(initialValue: status) + } + + var body: some View { + if scrolls { + ScrollView { column } + .task(id: reload) { await load() } + } else { + column + } + } + + private var column: some View { + VStack(alignment: .leading, spacing: 6) { + if let status { + content(status) + } else if error == nil { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.top, 20) + } + if let error { + Text(error) + .font(.system(size: 12)) + .foregroundStyle(Theme.secondaryText) + .padding(.horizontal, 4) + if status == nil { + Button("Try again") { Task { await load() } } + .buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white)) + } + } + } + .padding(.horizontal, 2) + } + + private func load() async { + do { + status = try await model.api.status() + error = nil + } catch { + self.error = model.message(for: error) + } + } +} + +/// A bold line and an optional grey one under it, as on mock C's issue cards. +private struct RowText: View { + var title: String + var detail: String? + + var body: some View { + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + if let detail { + Text(detail) + .font(.system(size: 11)) + .foregroundStyle(Theme.secondaryText) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +#Preview("Status") { + NavigationStack { + StatusView() + } + .environment(AppModel.preview()) + .fontDesign(.rounded) +} + +#Preview("Environment") { + NavigationStack { + HostStatusView(hostId: "laptop") + } + .environment(AppModel.preview()) + .fontDesign(.rounded) +} + +#Preview("Notifications") { + NavigationStack { + NotificationsView() + } + .environment(AppModel.preview()) + .fontDesign(.rounded) +} diff --git a/watch/WristCode/Views/Theme.swift b/watch/WristCode/Views/Theme.swift index 098d8bc..a401d7f 100644 --- a/watch/WristCode/Views/Theme.swift +++ b/watch/WristCode/Views/Theme.swift @@ -1,4 +1,5 @@ -// Mock B colors and the shared card. Amber means it needs you, teal means working, grey means done. +// Mock B colors and the shared card. Amber means it needs you, teal means working, grey means done, +// and red means broken. import SwiftUI @@ -10,6 +11,7 @@ enum Theme { 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 + static let bad = Color(red: 1.0, green: 0.420, blue: 0.369) // #ff6b5e } extension Phase { @@ -87,6 +89,25 @@ enum Format { return "\(hours / 24) d" } + /// How long until a date, such as "40 s", "3 min" or "2 h". + static func countdown(to date: Date, now: Date = .now) -> String { + let seconds = max(0, Int(date.timeIntervalSince(now).rounded())) + if seconds < 60 { return "\(seconds) s" } + if seconds < 3600 { return "\(seconds / 60) min" } + return "\(seconds / 3600) h" + } + + /// "expires in 29 days", "expires today" or "expired 2 days ago", for a token. + static func expiry(_ date: Date, now: Date = .now) -> String { + let days = Int((date.timeIntervalSince(now) / 86_400).rounded(.down)) + if date < now { + let ago = Int(now.timeIntervalSince(date) / 86_400) + return ago < 1 ? "expired today" : "expired \(ago) day\(ago == 1 ? "" : "s") ago" + } + if days < 1 { return "expires today" } + return "expires in \(days) day\(days == 1 ? "" : "s")" + } + /// A model id made readable: `claude-opus-5-5` becomes "Claude Opus 5.5". static func modelName(_ id: String) -> String { var words: [String] = [] diff --git a/watch/WristCode/WristCodeApp.swift b/watch/WristCode/WristCodeApp.swift index 9cdd53f..85387df 100644 --- a/watch/WristCode/WristCodeApp.swift +++ b/watch/WristCode/WristCodeApp.swift @@ -33,6 +33,12 @@ struct RootView: View { ThreadView(hostId: hostId, threadId: threadId) case .newThread: NewThreadView() + case .status: + StatusView() + case let .host(id): + HostStatusView(hostId: id) + case .notifications: + NotificationsView() } } } diff --git a/watch/WristCodeTests/ModelsTests.swift b/watch/WristCodeTests/ModelsTests.swift index 2f90138..3eaa950 100644 --- a/watch/WristCodeTests/ModelsTests.swift +++ b/watch/WristCodeTests/ModelsTests.swift @@ -139,6 +139,33 @@ private let feedItemJSON = """ #expect(try decode(ErrorResponse.self, #"{ "error": "That host is offline" }"#).error == "That host is offline") } + @Test func statusFromDocs() throws { + let status = try decode(StatusResponse.self, """ + { + "hosts": [{ + "id": "laptop", "label": "Laptop", "status": "online", "error": null, + "activeUrl": "http://laptopvpn.gib:3773", + "routes": [ + { "url": "http://laptop.gib:3773", "kind": "direct", "result": "failed", "error": "no answer in 5 s", "checkedAt": "2026-11-01T09:12:00.000Z" }, + { "url": "http://laptopvpn.gib:3773", "kind": "vpn", "result": "ok", "error": null, "checkedAt": "2026-11-01T09:12:00.000Z" }, + { "url": "https://t3laptop.gbrown.org", "kind": "public", "result": "untried", "error": null, "checkedAt": null } + ], + "onlineSince": "2026-11-01T09:12:00.000Z", "lastOnlineAt": null, "nextRetryAt": null, + "tokenExpiresAt": "2026-12-01T00:00:00.000Z", "serverVersion": "0.1.5" + }], + "gateway": { "startedAt": "2026-11-01T08:00:00.000Z", "pushConfigured": true, + "lastPush": { "ok": false, "at": "2026-11-01T09:40:00.000Z", "status": 400, "reason": "BadDeviceToken" } }, + "device": { "name": "Apple Watch", "pairedAt": "2026-10-30T08:00:00.000Z", "pushRegistered": true, "apnsEnvironment": null } + } + """) + let host = try #require(status.hosts.first) + #expect(host.onFallback) + #expect(host.activeRoute?.kind == .vpn) + #expect(host.routes.map(\.hostPort) == ["laptop.gib:3773", "laptopvpn.gib:3773", "t3laptop.gbrown.org"]) + #expect(status.device.apnsEnvironment == nil) + #expect(status.gateway.lastPush?.hint?.contains("sandbox/production") == true) + } + @Test func badDateFails() { #expect(throws: DecodingError.self) { try decode(Message.self, #"{ "id": "m", "role": "user", "text": "x", "createdAt": "yesterday" }"#) @@ -236,6 +263,9 @@ private let feedItemJSON = """ #expect(thread.url.absoluteString == "wristcode://thread/mac%20mini/t%2F1") #expect(Route(url: thread.url) == thread) #expect(Route(url: Route.newThread.url) == .newThread) + for route in [Route.status, .host(id: "mac mini"), .notifications] { + #expect(Route(url: route.url) == route) + } #expect(Route(url: URL(string: "wristcode://thread/only-host")!) == nil) #expect(Route(url: URL(string: "https://thread/h/t")!) == nil) } @@ -322,6 +352,41 @@ private let feedItemJSON = """ } } +@Suite struct StatusReportTests { + private let now = Date(timeIntervalSince1970: 1_800_000_000) + + @Test func demoListsIssuesWorstFirst() { + let report = StatusReport(DemoFixtures(now: now).status, now: now) + #expect(report.issues.map(\.title) == [ + "LittleMan offline", "Laptop is on its VPN route", "server-gib token expires in 5 days", + ]) + #expect(report.issues.map(\.detail) == ["Both routes failed · 4 min", "laptop.gib:3773 did not answer", "Run renew-hosts.sh"]) + #expect(!report.allConnected) + #expect(report.worst == .problem) + #expect(report.healthySummary == "2 more, all direct") + #expect(report.subline == "4 environments · pushes OK") + } + + @Test func fallbackAloneIsStillAllConnected() { + var status = DemoFixtures(now: now).status + status.hosts.removeAll { $0.id == "littleman" } + for i in status.hosts.indices { status.hosts[i].tokenExpiresAt = now.addingTimeInterval(30 * 86_400) } + let report = StatusReport(status, now: now) + #expect(report.allConnected) + #expect(report.issues.map(\.kind) == [.fallback]) + } + + @Test func pushProblems() { + var status = DemoFixtures(now: now).status + status.device.pushRegistered = false + #expect(StatusReport(status, now: now).pushSummary == "watch not registered") + status.gateway.pushConfigured = false + let report = StatusReport(status, now: now) + #expect(report.pushSummary == "pushes off") + #expect(report.issues.first { $0.kind == .push }?.route == .notifications) + } +} + @Suite struct DemoGatewayTests { @Test func allowMovesThreadToWorking() async throws { let gateway = DemoGateway() diff --git a/watch/WristCodeTests/SnapshotTests.swift b/watch/WristCodeTests/SnapshotTests.swift index ca69311..cf4caf3 100644 --- a/watch/WristCodeTests/SnapshotTests.swift +++ b/watch/WristCodeTests/SnapshotTests.swift @@ -42,6 +42,29 @@ struct SnapshotTests { try write(view, "thread-multiselect.png") } + @Test func statusScreens() async throws { + let status = try await DemoGateway().status() + try write(titled("Status", StatusView(status: status, scrolls: false)), "status.png") + try write(titled("Laptop", HostStatusView(hostId: "laptop", status: status, scrolls: false)), "status-environment.png") + let result = PushResult(ok: true, at: .now, status: 200, reason: nil) + try write(titled("Notifications", NotificationsView(status: status, result: result, scrolls: false)), "status-notifications.png") + } + + /// The screen under a title like the navigation bar's, which ImageRenderer leaves out. + private func titled(_ title: String, _ view: some View) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 10) + .padding(.top, 10) + view + } + .environment(AppModel.preview()) + .fontDesign(.rounded) + .tint(Theme.work) + } + /// Renders at the screen's width and as tall as the content, as if scrolled through. private func write(_ view: some View, _ name: String) throws { let renderer = ImageRenderer(content: view diff --git a/watch/screenshots/status-environment.png b/watch/screenshots/status-environment.png new file mode 100644 index 0000000..c4bd55c Binary files /dev/null and b/watch/screenshots/status-environment.png differ diff --git a/watch/screenshots/status-notifications.png b/watch/screenshots/status-notifications.png new file mode 100644 index 0000000..fc8580c Binary files /dev/null and b/watch/screenshots/status-notifications.png differ diff --git a/watch/screenshots/status.png b/watch/screenshots/status.png new file mode 100644 index 0000000..5d095ac Binary files /dev/null and b/watch/screenshots/status.png differ