Files
wristcode/watch/WristCode/AppModel.swift
T
gib 7ca0362745 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.
2026-11-01 15:16:18 -05:00

232 lines
7.5 KiB
Swift

// App-wide state: whether the watch is paired, the navigation path, and the latest feed. It also
// keeps the complication current: it shares the mode and latest counts through the shared Keychain
// group and reloads the complication's timeline when they change.
import Foundation
import Observation
import UserNotifications
import WatchKit
import WidgetKit
@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 {
didSet { if mode != oldValue { shareMode() } }
}
var path: [Route] = []
private(set) var feed: Feed?
private(set) var feedError: String?
private var apnsToken: String?
private var refreshing = false
/// The counts the complication was last told about.
private var sharedSummary: Summary?
init(mode: Mode, feed: Feed? = nil, path: [Route] = []) {
self.mode = mode
self.feed = feed
self.path = path
}
/// `-demo` starts in demo mode. `-screen new|status|notifications`, `-host <hostId>` or
/// `-thread <hostId>/<threadId>` 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
func argument(_ flag: String) -> String? {
guard demo, let i = args.firstIndex(of: flag), i + 1 < args.count else { return nil }
return args[i + 1]
}
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)
shareMode()
}
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 = []
}
/// 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 }
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 {
let feed = try await api.feed()
self.feed = feed
feedError = nil
share(Summary(feed))
} catch {
feedError = message(for: error)
}
}
func open(_ route: Route) {
path = [route]
}
/// Opens a `wristcode://` link from the complication.
func open(url: URL) {
guard mode != .unpaired, let route = Route(url: url) else { return }
open(route)
}
// MARK: - Complication
/// Tells the complication about the mode. Counts from another mode, or from before signing
/// out, are forgotten.
private func shareMode() {
let demo = mode == .demo
if SharedState.demo != demo || mode == .unpaired { SharedState.lastSummary = nil }
SharedState.demo = demo
sharedSummary = nil
reloadComplication()
}
/// Hands the complication fresh counts. The feed refreshes every 10 s while on screen, so the
/// timeline only reloads when the counts or the next thread change.
private func share(_ summary: Summary) {
guard summary != sharedSummary else { return }
sharedSummary = summary
SharedState.lastSummary = summary
reloadComplication()
}
/// Asks the complication to fetch the summary again, as after a push or a notification action.
func reloadComplication() {
WidgetCenter.shared.reloadAllTimelines()
}
// 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() {
reloadComplication()
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)
}
}